From c16a6753f402fcbbf06af029bd9d2bb2a51b613a Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 13 Oct 2026 02:29:27 +0300 Subject: [PATCH] added http digest auth to download clients --- .../DownloadClientControllerTests.cs | 103 + .../DownloadClientResponseContractTests.cs | 38 + .../Requests/CreateDownloadClientRequest.cs | 14 + .../Requests/TestDownloadClientRequest.cs | 31 +- .../Requests/UpdateDownloadClientRequest.cs | 9 + .../Responses/DownloadClientConfigResponse.cs | 10 + .../Controllers/DownloadClientController.cs | 9 +- .../Enums/DownloadClientAuthField.cs | 10 + .../Enums/DownloadClientTypeNameExtensions.cs | 36 +- .../Cleanuparr.Domain/Enums/HttpAuthType.cs | 10 + .../RTorrent/RTorrentClientTests.cs | 39 +- .../TransmissionServiceHttpAuthTests.cs | 88 + .../UTorrent/UTorrentHttpServiceTests.cs | 86 + .../Http/BasicAuthHttpMessageHandlerTests.cs | 53 + .../Http/DigestAuthRoundTripTests.cs | 72 + .../DynamicHttpClientConfigurationTests.cs | 71 + .../Http/DynamicHttpClientProviderTests.cs | 16 +- .../Persistence/EnumFallbackParityTests.cs | 1 + .../DownloadClient/RTorrent/RTorrentClient.cs | 4 +- .../Transmission/TransmissionService.cs | 8 +- .../UTorrent/UTorrentHttpService.cs | 25 +- .../Http/DynamicHttpClientProvider.cs | 5 +- .../BasicAuthHttpMessageHandler.cs | 27 + .../DynamicHttpClientConfiguration.cs | 94 +- .../DynamicHttpClientFactory.cs | 16 +- .../HttpClientConfig.cs | 15 + .../IDynamicHttpClientFactory.cs | 12 +- ...3733_AddDownloadClientHttpAuth.Designer.cs | 2050 +++++++++++++++++ ...0261012213733_AddDownloadClientHttpAuth.cs | 55 + .../Data/DataContextModelSnapshot.cs | 13 + ...3725_AddDownloadClientHttpAuth.Designer.cs | 2046 ++++++++++++++++ ...0261012213725_AddDownloadClientHttpAuth.cs | 49 + .../Data/DataContextModelSnapshot.cs | 13 + .../DownloadClientConfigTests.cs | 211 ++ .../DownloadClientTypeNameExtensionsTests.cs | 38 + .../Configuration/DownloadClientConfig.cs | 34 + .../DownloadClientConfigExtensions.cs | 31 + 37 files changed, 5376 insertions(+), 66 deletions(-) create mode 100644 code/backend/Cleanuparr.Domain/Enums/HttpAuthType.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceHttpAuthTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentHttpServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/BasicAuthHttpMessageHandlerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/DigestAuthRoundTripTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/BasicAuthHttpMessageHandler.cs create mode 100644 code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.cs create mode 100644 code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.cs create mode 100644 code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfigExtensions.cs diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs index 9b9f2ab9..602445b8 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs @@ -144,6 +144,68 @@ public class DownloadClientControllerTests : IDisposable exception.Message.ShouldBe("Use either an API key or a username and password, not both"); } + [Fact] + public async Task CreateDownloadClientConfig_QBittorrentWithApiKeyAndHttpAuthUsername_PropagatesValidationException() + { + // Arrange + var request = new CreateDownloadClientRequest + { + Enabled = true, + Name = "qbit-httpauth-conflict-client", + TypeName = DownloadClientTypeName.qBittorrent, + Host = "http://localhost:8080", + ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12", + HttpAuthUsername = "proxyuser", + }; + + // Act / Assert + Cleanuparr.Domain.Exceptions.ValidationException exception = await Should.ThrowAsync( + () => _controller.CreateDownloadClientConfig(request)); + exception.Message.ShouldBe("API key can't be combined with HTTP authentication. Use qBittorrent username/password instead."); + } + + [Fact] + public async Task CreateDownloadClientConfig_DigestWithoutUsername_PropagatesValidationException() + { + // Arrange + var request = new CreateDownloadClientRequest + { + Enabled = true, + Name = "rtorrent-digest-no-creds", + TypeName = DownloadClientTypeName.rTorrent, + Host = "http://localhost:8080", + HttpAuthType = HttpAuthType.Digest, + }; + + // Act / Assert + Cleanuparr.Domain.Exceptions.ValidationException exception = await Should.ThrowAsync( + () => _controller.CreateDownloadClientConfig(request)); + exception.Message.ShouldBe("HTTP Digest authentication requires a username"); + } + + [Fact] + public async Task CreateDownloadClientConfig_DigestWithUsername_PersistsHttpAuthType() + { + // Arrange + var request = new CreateDownloadClientRequest + { + Enabled = true, + Name = "rtorrent-digest-with-creds", + TypeName = DownloadClientTypeName.rTorrent, + Host = "http://localhost:8080", + HttpAuthType = HttpAuthType.Digest, + Username = "proxyuser", + Password = "proxypass", + }; + + // Act + await _controller.CreateDownloadClientConfig(request); + + // Assert + var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Name == "rtorrent-digest-with-creds"); + saved.HttpAuthType.ShouldBe(HttpAuthType.Digest); + } + [Fact] public async Task CreateDownloadClientConfig_SabnzbdWithNoType_StoresUsenet() { @@ -282,6 +344,7 @@ public class DownloadClientControllerTests : IDisposable var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id); saved.Name.ShouldBe("renamed"); saved.Host!.ToString().ShouldContain("newhost"); + _dynamicHttpClientFactory.Received(1).UnregisterConfiguration($"DownloadClient_{client.Id}"); } [Fact] @@ -405,6 +468,7 @@ public class DownloadClientControllerTests : IDisposable DownloadClientConfig saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id); saved.TypeName.ShouldBe(DownloadClientTypeName.qBittorrent); + _dynamicHttpClientFactory.DidNotReceive().UnregisterConfiguration(Arg.Any()); } [Fact] @@ -507,6 +571,45 @@ public class DownloadClientControllerTests : IDisposable _downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any()); } + [Fact] + public async Task TestDownloadClient_QBittorrentWithApiKeyAndHttpAuthUsername_ReturnsBadRequest() + { + // Arrange + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Host = "http://localhost:8080", + ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12", + HttpAuthUsername = "proxyuser", + }; + + // Act + var result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + _downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any()); + } + + [Fact] + public async Task TestDownloadClient_DigestWithoutUsername_ReturnsBadRequest() + { + // Arrange + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.rTorrent, + Host = "http://localhost:8080", + HttpAuthType = HttpAuthType.Digest, + }; + + // Act + var result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + _downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any()); + } + [Fact] public async Task TestDownloadClient_NewApiKeyWithPlaceholderPassword_UsesNewApiKeyAndStoredPassword() { diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs index 7c507fac..9ccb47e6 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs @@ -108,6 +108,41 @@ public class DownloadClientResponseContractTests : IDisposable ResponseContract.Body(result).GetProperty("password").GetString().ShouldBe(SensitiveDataHelper.Placeholder); } + [Fact] + public async Task GetDownloadClientConfig_MasksTheHttpAuthPassword() + { + DownloadClientConfig client = new() + { + Id = Guid.NewGuid(), + Name = "contract", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthUsername = "proxyuser", + HttpAuthPassword = "proxysecret", + }; + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + IActionResult result = await _controller.GetDownloadClientConfig(); + + FirstClient(result).GetProperty("httpAuthPassword").GetString().ShouldBe(SensitiveDataHelper.Placeholder); + } + + [Fact] + public async Task CreateDownloadClientConfig_MasksTheHttpAuthPassword() + { + CreateDownloadClientRequest request = NewCreateRequest() with + { + HttpAuthUsername = "proxyuser", + HttpAuthPassword = "proxysecret", + }; + + IActionResult result = await _controller.CreateDownloadClientConfig(request); + + ResponseContract.Body(result).GetProperty("httpAuthPassword").GetString().ShouldBe(SensitiveDataHelper.Placeholder); + } + [Fact] public void GetDownloadClientTypes_ReturnsTheDocumentedWrapperKeys() { @@ -156,6 +191,9 @@ public class DownloadClientResponseContractTests : IDisposable "enabled", "externalUrl", "host", + "httpAuthPassword", + "httpAuthType", + "httpAuthUsername", "id", "name", "password", diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs index 6cee018b..81a96b74 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs @@ -23,6 +23,12 @@ public sealed record CreateDownloadClientRequest public string? ApiKey { get; init; } + public HttpAuthType HttpAuthType { get; init; } + + public string? HttpAuthUsername { get; init; } + + public string? HttpAuthPassword { get; init; } + public string? UrlBase { get; init; } public string? ExternalUrl { get; init; } @@ -68,6 +74,11 @@ public sealed record CreateDownloadClientRequest throw new CustomValidationException("API key cannot be a placeholder value"); } + if (HttpAuthPassword.IsPlaceholder()) + { + throw new ValidationException("HTTP auth password cannot be a placeholder value"); + } + if (TypeName.RequiresAuthField(DownloadClientAuthField.ApiKey) && string.IsNullOrWhiteSpace(ApiKey)) { throw new CustomValidationException("API key cannot be empty"); @@ -84,6 +95,9 @@ public sealed record CreateDownloadClientRequest Username = Username, Password = Password, ApiKey = ApiKey, + HttpAuthType = HttpAuthType, + HttpAuthUsername = HttpAuthUsername, + HttpAuthPassword = HttpAuthPassword, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, DownloadDirectorySource = DownloadDirectorySource, diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs index c361d145..6b3eb200 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs @@ -19,6 +19,12 @@ public sealed record TestDownloadClientRequest public string? ApiKey { get; init; } + public HttpAuthType HttpAuthType { get; init; } + + public string? HttpAuthUsername { get; init; } + + public string? HttpAuthPassword { get; init; } + public string? UrlBase { get; init; } public Guid? ClientId { get; init; } @@ -50,12 +56,27 @@ public sealed record TestDownloadClientRequest { throw new CustomValidationException("Use either an API key or a username and password, not both"); } + + if (TypeName.ApiKeyConflictsWithHttpAuth() && !string.IsNullOrWhiteSpace(ApiKey) && + !string.IsNullOrWhiteSpace(HttpAuthUsername)) + { + throw new ValidationException( + "API key can't be combined with HTTP authentication. Use qBittorrent username/password instead."); + } + + string? digestUsername = TypeName.UsesDedicatedHttpAuthFields() ? HttpAuthUsername : Username; + + if (HttpAuthType == HttpAuthType.Digest && string.IsNullOrWhiteSpace(digestUsername)) + { + throw new ValidationException("HTTP Digest authentication requires a username"); + } } - public DownloadClientConfig ToTestConfig(string? resolvedPassword = null, string? resolvedApiKey = null) + public DownloadClientConfig ToTestConfig(string? resolvedPassword = null, string? resolvedApiKey = null, string? resolvedHttpAuthPassword = null) { var password = resolvedPassword ?? Password; var apiKey = resolvedApiKey ?? ApiKey; + var httpAuthPassword = resolvedHttpAuthPassword ?? HttpAuthPassword; if (password.IsPlaceholder()) { @@ -67,6 +88,11 @@ public sealed record TestDownloadClientRequest throw new CustomValidationException("API key cannot be a placeholder value"); } + if (httpAuthPassword.IsPlaceholder()) + { + throw new ValidationException("HTTP auth password cannot be a placeholder value"); + } + return new() { Id = Guid.NewGuid(), @@ -78,6 +104,9 @@ public sealed record TestDownloadClientRequest Username = Username, Password = password, ApiKey = apiKey, + HttpAuthType = HttpAuthType, + HttpAuthUsername = HttpAuthUsername, + HttpAuthPassword = httpAuthPassword, UrlBase = UrlBase, }; } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs index 0d2d380c..31a67a2e 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs @@ -23,6 +23,12 @@ public sealed record UpdateDownloadClientRequest public string? ApiKey { get; init; } + public HttpAuthType HttpAuthType { get; init; } + + public string? HttpAuthUsername { get; init; } + + public string? HttpAuthPassword { get; init; } + public string? UrlBase { get; init; } public string? ExternalUrl { get; init; } @@ -74,6 +80,9 @@ public sealed record UpdateDownloadClientRequest Username = Username, Password = Password.IsPlaceholder() ? existing.Password : Password, ApiKey = ApiKey.IsPlaceholder() ? existing.ApiKey : ApiKey, + HttpAuthType = HttpAuthType, + HttpAuthUsername = HttpAuthUsername, + HttpAuthPassword = HttpAuthPassword.IsPlaceholder() ? existing.HttpAuthPassword : HttpAuthPassword, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, DownloadDirectorySource = DownloadDirectorySource, diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs index 81ba35b6..4b541d24 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs @@ -29,6 +29,13 @@ public sealed record DownloadClientConfigResponse [SensitiveData] public string? ApiKey { get; init; } + public HttpAuthType HttpAuthType { get; init; } + + public string? HttpAuthUsername { get; init; } + + [SensitiveData] + public string? HttpAuthPassword { get; init; } + public string? UrlBase { get; init; } public Uri? ExternalUrl { get; init; } @@ -48,6 +55,9 @@ public sealed record DownloadClientConfigResponse Username = config.Username, Password = config.Password, ApiKey = config.ApiKey, + HttpAuthType = config.HttpAuthType, + HttpAuthUsername = config.HttpAuthUsername, + HttpAuthPassword = config.HttpAuthPassword, UrlBase = config.UrlBase, ExternalUrl = config.ExternalUrl, DownloadDirectorySource = config.DownloadDirectorySource, diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs index eb34d5d3..8fa7f915 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs @@ -113,6 +113,9 @@ public sealed class DownloadClientController : ControllerBase _dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist); await _dataContext.SaveChangesAsync(); + // Otherwise the cached handler keeps the old HTTP auth and URL. + _dynamicHttpClientFactory.UnregisterConfiguration($"DownloadClient_{id}"); + return Ok(DownloadClientConfigResponse.From(clientToPersist)); } finally @@ -170,8 +173,9 @@ public sealed class DownloadClientController : ControllerBase string? resolvedPassword = null; string? resolvedApiKey = null; + string? resolvedHttpAuthPassword = null; - if ((request.Password.IsPlaceholder() || request.ApiKey.IsPlaceholder()) && request.ClientId.HasValue) + if ((request.Password.IsPlaceholder() || request.ApiKey.IsPlaceholder() || request.HttpAuthPassword.IsPlaceholder()) && request.ClientId.HasValue) { var existingClient = await _dataContext.DownloadClients .AsNoTracking() @@ -184,9 +188,10 @@ public sealed class DownloadClientController : ControllerBase resolvedPassword = request.Password.IsPlaceholder() ? existingClient.Password : null; resolvedApiKey = request.ApiKey.IsPlaceholder() ? existingClient.ApiKey : null; + resolvedHttpAuthPassword = request.HttpAuthPassword.IsPlaceholder() ? existingClient.HttpAuthPassword : null; } - var testConfig = request.ToTestConfig(resolvedPassword, resolvedApiKey); + var testConfig = request.ToTestConfig(resolvedPassword, resolvedApiKey, resolvedHttpAuthPassword); using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig); var healthResult = await downloadService.HealthCheckAsync(); diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs index 64d95d71..ed1950c8 100644 --- a/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs @@ -8,4 +8,14 @@ public enum DownloadClientAuthField Username, Password, ApiKey, + + /// + /// Reverse proxy username for qBittorrent, Deluge and SABnzbd. + /// + HttpAuthUsername, + + /// + /// Reverse proxy password for qBittorrent, Deluge and SABnzbd. + /// + HttpAuthPassword, } diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs index b32bcdda..2a5f778a 100644 --- a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs @@ -1,15 +1,27 @@ +using System.Linq; + namespace Cleanuparr.Domain.Enums; public static class DownloadClientTypeNameExtensions { + private static readonly DownloadClientAuthField[] HttpAuthFields = + [ + DownloadClientAuthField.HttpAuthUsername, + DownloadClientAuthField.HttpAuthPassword, + ]; + private static readonly Dictionary AuthFields = new() { - [DownloadClientTypeName.qBittorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password, DownloadClientAuthField.ApiKey], - [DownloadClientTypeName.Deluge] = [DownloadClientAuthField.Password], + [DownloadClientTypeName.qBittorrent] = + [ + DownloadClientAuthField.Username, DownloadClientAuthField.Password, DownloadClientAuthField.ApiKey, + ..HttpAuthFields, + ], + [DownloadClientTypeName.Deluge] = [DownloadClientAuthField.Password, ..HttpAuthFields], [DownloadClientTypeName.Transmission] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], [DownloadClientTypeName.uTorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], [DownloadClientTypeName.rTorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], - [DownloadClientTypeName.Sabnzbd] = [DownloadClientAuthField.ApiKey], + [DownloadClientTypeName.Sabnzbd] = [DownloadClientAuthField.ApiKey, ..HttpAuthFields], }; /// @@ -34,9 +46,12 @@ public static class DownloadClientTypeNameExtensions /// /// True when the field is the client's only credential, so leaving it blank means it cannot authenticate at all. + /// Proxy credential fields don't count. /// public static bool RequiresAuthField(this DownloadClientTypeName typeName, DownloadClientAuthField field) => - AuthFields.TryGetValue(typeName, out var fields) && fields is [var only] && only == field; + AuthFields.TryGetValue(typeName, out var fields) && + fields.Where(f => f != DownloadClientAuthField.HttpAuthUsername && f != DownloadClientAuthField.HttpAuthPassword) + .ToArray() is [var only] && only == field; /// /// True when the client type accepts an API key alongside a username or password, so the two must not be mixed. @@ -45,6 +60,19 @@ public static class DownloadClientTypeNameExtensions typeName.SupportsAuthField(DownloadClientAuthField.ApiKey) && (typeName.SupportsAuthField(DownloadClientAuthField.Username) || typeName.SupportsAuthField(DownloadClientAuthField.Password)); + /// + /// True when the API key needs the Authorization header that HTTP auth also uses. + /// SABnzbd sends its key as a query parameter. + /// + public static bool ApiKeyConflictsWithHttpAuth(this DownloadClientTypeName typeName) => + typeName == DownloadClientTypeName.qBittorrent; + + /// + /// True when Username/Password log into the client itself, so HTTP auth needs separate fields. + /// + public static bool UsesDedicatedHttpAuthFields(this DownloadClientTypeName typeName) => + typeName.SupportsAuthField(DownloadClientAuthField.HttpAuthUsername); + private static readonly DownloadClientCapability[] BaseTorrentCapabilities = [ DownloadClientCapability.QueueCheck, diff --git a/code/backend/Cleanuparr.Domain/Enums/HttpAuthType.cs b/code/backend/Cleanuparr.Domain/Enums/HttpAuthType.cs new file mode 100644 index 00000000..c6933986 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/HttpAuthType.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Domain.Enums; + +/// +/// Scheme a download client uses to answer an HTTP auth challenge. +/// +public enum HttpAuthType +{ + Basic, + Digest, +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrent/RTorrentClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrent/RTorrentClientTests.cs index 657dc57f..37df7de0 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrent/RTorrentClientTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrent/RTorrentClientTests.cs @@ -11,7 +11,11 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.RTorrent; public class RTorrentClientTests { - private static (RTorrentClient client, FakeHttpMessageHandler handler) CreateClient(string responseXml) + private static (RTorrentClient client, FakeHttpMessageHandler handler) CreateClient( + string responseXml, + HttpAuthType httpAuthType = HttpAuthType.Basic, + string? username = null, + string? password = null) { FakeHttpMessageHandler handler = new(); handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) @@ -25,6 +29,9 @@ public class RTorrentClientTests TypeName = DownloadClientTypeName.rTorrent, Type = DownloadClientType.Torrent, Host = new Uri("http://localhost:8080"), + HttpAuthType = httpAuthType, + Username = username, + Password = password, }; return (new RTorrentClient(config, new HttpClient(handler)), handler); @@ -60,4 +67,34 @@ public class RTorrentClientTests await Should.ThrowAsync(() => client.StopTorrentAsync("HASH123")); } + + [Fact] + public async Task StopTorrentAsync_InBasicMode_SendsBasicHeader() + { + const string response = """ + + 0 + """; + (RTorrentClient client, FakeHttpMessageHandler handler) = + CreateClient(response, HttpAuthType.Basic, username: "user", password: "pass"); + + await client.StopTorrentAsync("HASH123"); + + handler.CapturedRequests[0].Headers.Authorization!.Scheme.ShouldBe("Basic"); + } + + [Fact] + public async Task StopTorrentAsync_InDigestMode_SendsNoManualHeader() + { + const string response = """ + + 0 + """; + (RTorrentClient client, FakeHttpMessageHandler handler) = + CreateClient(response, HttpAuthType.Digest, username: "user", password: "pass"); + + await client.StopTorrentAsync("HASH123"); + + handler.CapturedRequests[0].Headers.Authorization.ShouldBeNull(); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceHttpAuthTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceHttpAuthTests.cs new file mode 100644 index 00000000..5255237e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceHttpAuthTests.cs @@ -0,0 +1,88 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; +using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Http; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +/// +/// Uses the public constructor, since the test constructor skips the FLM.Transmission client. +/// +public sealed class TransmissionServiceHttpAuthTests +{ + private static (TransmissionService service, FakeHttpMessageHandler handler) CreateService(HttpAuthType httpAuthType) + { + FakeHttpMessageHandler handler = new(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("""{"result":"success","arguments":{}}""") + })); + + DownloadClientConfig config = new() + { + Id = Guid.NewGuid(), + Name = "transmission", + TypeName = DownloadClientTypeName.Transmission, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:9091"), + HttpAuthType = httpAuthType, + Username = "user", + Password = "pass", + }; + + IDynamicHttpClientProvider httpClientProvider = Substitute.For(); + httpClientProvider.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); + + IDryRunInterceptor dryRunInterceptor = Substitute.For(); + dryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); + + TransmissionService service = new( + NullLogger.Instance, + Substitute.For(), + Substitute.For(), + dryRunInterceptor, + Substitute.For(), + httpClientProvider, + Substitute.For(), + Substitute.For(), + config, + Substitute.For(), + Substitute.For(), + TimeProvider.System); + + return (service, handler); + } + + [Fact] + public async Task LoginAsync_InBasicMode_SendsBasicHeader() + { + (TransmissionService service, FakeHttpMessageHandler handler) = CreateService(HttpAuthType.Basic); + + await service.LoginAsync(); + + handler.CapturedRequests[0].Headers.Authorization!.Scheme.ShouldBe("Basic"); + } + + [Fact] + public async Task LoginAsync_InDigestMode_SendsNoManualHeader() + { + (TransmissionService service, FakeHttpMessageHandler handler) = CreateService(HttpAuthType.Digest); + + await service.LoginAsync(); + + handler.CapturedRequests[0].Headers.Authorization.ShouldBeNull(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentHttpServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentHttpServiceTests.cs new file mode 100644 index 00000000..bf8f4046 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentHttpServiceTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using Cleanuparr.Domain.Entities.UTorrent.Request; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.UTorrent; + +public sealed class UTorrentHttpServiceTests +{ + private static (UTorrentHttpService service, FakeHttpMessageHandler handler) CreateService(HttpAuthType httpAuthType) + { + FakeHttpMessageHandler handler = new(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + })); + + DownloadClientConfig config = new() + { + Name = "utorrent", + TypeName = DownloadClientTypeName.uTorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = httpAuthType, + Username = "user", + Password = "pass", + }; + + return (new UTorrentHttpService(new HttpClient(handler), config, NullLogger.Instance), handler); + } + + [Fact] + public async Task SendRawRequestAsync_InBasicMode_SendsBasicHeader() + { + (UTorrentHttpService service, FakeHttpMessageHandler handler) = CreateService(HttpAuthType.Basic); + + await service.SendRawRequestAsync(new UTorrentRequest { Action = "list" }, "GUID=abc"); + + handler.CapturedRequests[0].Headers.Authorization!.Scheme.ShouldBe("Basic"); + } + + [Fact] + public async Task SendRawRequestAsync_InDigestMode_SendsNoManualHeader() + { + (UTorrentHttpService service, FakeHttpMessageHandler handler) = CreateService(HttpAuthType.Digest); + + await service.SendRawRequestAsync(new UTorrentRequest { Action = "list" }, "GUID=abc"); + + handler.CapturedRequests[0].Headers.Authorization.ShouldBeNull(); + } + + [Fact] + public async Task GetTokenAndCookieAsync_InBasicMode_SendsBasicHeader() + { + (UTorrentHttpService service, FakeHttpMessageHandler handler) = CreateService(HttpAuthType.Basic); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("
tok
"), + Headers = { { "Set-Cookie", "GUID=abc; path=/" } }, + })); + + await service.GetTokenAndCookieAsync(); + + handler.CapturedRequests[0].Headers.Authorization!.Scheme.ShouldBe("Basic"); + } + + [Fact] + public async Task GetTokenAndCookieAsync_InDigestMode_SendsNoManualHeader() + { + (UTorrentHttpService service, FakeHttpMessageHandler handler) = CreateService(HttpAuthType.Digest); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("
tok
"), + Headers = { { "Set-Cookie", "GUID=abc; path=/" } }, + })); + + await service.GetTokenAndCookieAsync(); + + handler.CapturedRequests[0].Headers.Authorization.ShouldBeNull(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/BasicAuthHttpMessageHandlerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/BasicAuthHttpMessageHandlerTests.cs new file mode 100644 index 00000000..3ded1fac --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/BasicAuthHttpMessageHandlerTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Http.Headers; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +public sealed class BasicAuthHttpMessageHandlerTests +{ + private sealed class CapturingHandler : DelegatingHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + private static (HttpClient client, CapturingHandler inner) CreateClient(NetworkCredential credential) + { + CapturingHandler inner = new(); + BasicAuthHttpMessageHandler basicAuth = new(credential) { InnerHandler = inner }; + return (new HttpClient(basicAuth), inner); + } + + [Fact] + public async Task SendAsync_AddsPreemptiveBasicHeader() + { + (HttpClient client, CapturingHandler inner) = CreateClient(new NetworkCredential("user", "pass")); + + await client.GetAsync("http://localhost/"); + + inner.LastRequest!.Headers.Authorization!.Scheme.ShouldBe("Basic"); + inner.LastRequest!.Headers.Authorization!.Parameter.ShouldBe(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("user:pass"))); + } + + [Fact] + public async Task SendAsync_DoesNotOverwriteAnExistingAuthorizationHeader() + { + (HttpClient client, CapturingHandler inner) = CreateClient(new NetworkCredential("user", "pass")); + + using HttpRequestMessage request = new(HttpMethod.Get, "http://localhost/"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "existing-token"); + + await client.SendAsync(request); + + inner.LastRequest!.Headers.Authorization!.Scheme.ShouldBe("Bearer"); + inner.LastRequest!.Headers.Authorization!.Parameter.ShouldBe("existing-token"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DigestAuthRoundTripTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DigestAuthRoundTripTests.cs new file mode 100644 index 00000000..7c5d0188 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DigestAuthRoundTripTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Sockets; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +/// +/// Answers a real Digest challenge with the handler from . +/// +public sealed class DigestAuthRoundTripTests +{ + private static int GetFreePort() + { + using TcpListener probe = new(IPAddress.Loopback, 0); + probe.Start(); + int port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + [Fact] + public async Task DigestChallenge_IsAnsweredOnRetry() + { + int port = GetFreePort(); + string prefix = $"http://127.0.0.1:{port}/"; + + using HttpListener listener = new(); + listener.Prefixes.Add(prefix); + listener.Start(); + + Task serverTask = AnswerDigestChallengeAsync(listener); + + using HttpClientHandler handler = new(); + List additionalHandlers = []; + HttpClientConfig config = new() + { + Type = HttpClientType.WithRetry, + HttpAuthType = HttpAuthType.Digest, + HttpAuthCredential = new NetworkCredential("user", "pass"), + BaseUri = new Uri(prefix), + }; + DynamicHttpClientConfiguration.ApplyHttpAuth(additionalHandlers, handler, config); + + using HttpClient client = new(handler); + HttpResponseMessage response = await client.GetAsync(prefix); + + string? capturedAuthorizationHeader = await serverTask; + listener.Stop(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + capturedAuthorizationHeader.ShouldNotBeNull(); + capturedAuthorizationHeader.ShouldStartWith("Digest "); + capturedAuthorizationHeader.ShouldContain("username=\"user\""); + } + + private static async Task AnswerDigestChallengeAsync(HttpListener listener) + { + HttpListenerContext challenge = await listener.GetContextAsync(); + challenge.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + challenge.Response.Headers.Add("WWW-Authenticate", "Digest realm=\"test\", nonce=\"abc\", qop=\"auth\""); + challenge.Response.Close(); + + HttpListenerContext authenticated = await listener.GetContextAsync(); + string? authorizationHeader = authenticated.Request.Headers["Authorization"]; + authenticated.Response.StatusCode = (int)HttpStatusCode.OK; + authenticated.Response.Close(); + return authorizationHeader; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs index 84e80ea9..b3782049 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; using Cleanuparr.Shared.Helpers; using Shouldly; @@ -8,6 +9,76 @@ namespace Cleanuparr.Infrastructure.Tests.Http; public sealed class DynamicHttpClientConfigurationTests { + [Theory] + [InlineData(HttpClientType.Default)] + [InlineData(HttpClientType.WithRetry)] + [InlineData(HttpClientType.Deluge)] + [InlineData(HttpClientType.UTorrent)] + public void ApplyHttpAuth_InDigestMode_SetsCredentialsAndPreAuthenticate(HttpClientType clientType) + { + using HttpClientHandler handler = new(); + List additionalHandlers = []; + HttpClientConfig config = new() + { + Type = clientType, + HttpAuthType = HttpAuthType.Digest, + HttpAuthCredential = new NetworkCredential("user", "pass"), + BaseUri = new Uri("http://localhost:8080"), + }; + + DynamicHttpClientConfiguration.ApplyHttpAuth(additionalHandlers, handler, config); + + handler.PreAuthenticate.ShouldBeTrue(); + CredentialCache cache = handler.Credentials.ShouldBeOfType(); + cache.GetCredential(config.BaseUri, "Digest").ShouldNotBeNull(); + additionalHandlers.ShouldBeEmpty(); + } + + [Theory] + [InlineData(HttpClientType.Default)] + [InlineData(HttpClientType.WithRetry)] + [InlineData(HttpClientType.Deluge)] + [InlineData(HttpClientType.UTorrent)] + public void ApplyHttpAuth_InBasicMode_AddsDelegatingHandlerInstead(HttpClientType clientType) + { + using HttpClientHandler handler = new(); + List additionalHandlers = []; + HttpClientConfig config = new() + { + Type = clientType, + HttpAuthType = HttpAuthType.Basic, + HttpAuthCredential = new NetworkCredential("user", "pass"), + BaseUri = new Uri("http://localhost:8080"), + }; + + DynamicHttpClientConfiguration.ApplyHttpAuth(additionalHandlers, handler, config); + + handler.Credentials.ShouldBeNull(); + handler.PreAuthenticate.ShouldBeFalse(); + additionalHandlers.ShouldHaveSingleItem(); + additionalHandlers[0].ShouldBeOfType(); + } + + [Fact] + public void ApplyHttpAuth_WithNoCredential_DoesNothing() + { + using HttpClientHandler handler = new(); + List additionalHandlers = []; + HttpClientConfig config = new() + { + Type = HttpClientType.WithRetry, + HttpAuthType = HttpAuthType.Digest, + HttpAuthCredential = null, + BaseUri = new Uri("http://localhost:8080"), + }; + + DynamicHttpClientConfiguration.ApplyHttpAuth(additionalHandlers, handler, config); + + handler.Credentials.ShouldBeNull(); + handler.PreAuthenticate.ShouldBeFalse(); + additionalHandlers.ShouldBeEmpty(); + } + [Fact] public void IsRetryable_ShouldReturnFalse_WhenResponseIsConflict() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs index 94cd1441..16f624f2 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; @@ -57,7 +58,10 @@ public sealed class DynamicHttpClientProviderTests : IDisposable expected, Arg.Any(), Arg.Any(), - Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Theory] @@ -75,7 +79,10 @@ public sealed class DynamicHttpClientProviderTests : IDisposable Arg.Any(), Arg.Any(), Arg.Any(), - sendUserAgent); + sendUserAgent, + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -91,7 +98,10 @@ public sealed class DynamicHttpClientProviderTests : IDisposable Arg.Any(), Arg.Is(retry => retry.MaxRetries == 4 && retry.ExcludeUnauthorized), CertificateValidationType.DisabledForLocalAddresses, - false); + false, + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Persistence/EnumFallbackParityTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Persistence/EnumFallbackParityTests.cs index 01c04f87..e224e4b9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Persistence/EnumFallbackParityTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Persistence/EnumFallbackParityTests.cs @@ -35,6 +35,7 @@ public class EnumFallbackParityTests ["NtfyConfig.Priority"] = NtfyPriority.Default, ["PushoverConfig.Priority"] = PushoverPriority.Normal, ["AppriseConfig.Mode"] = AppriseMode.Api, + ["DownloadClientConfig.HttpAuthType"] = HttpAuthType.Basic, }; [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs index 1b59bdd0..21b42fb2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Text; using System.Xml.Linq; using Cleanuparr.Domain.Entities.RTorrent.Response; +using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Persistence.Models.Configuration; @@ -218,7 +219,8 @@ public sealed class RTorrentClient var request = new HttpRequestMessage(HttpMethod.Post, _config.Url) { Content = content }; - if (!string.IsNullOrEmpty(_config.Username)) + // In Digest mode the handler's CredentialCache answers instead. + if (_config.HttpAuthType == HttpAuthType.Basic && !string.IsNullOrEmpty(_config.Username)) { var credentials = Convert.ToBase64String( Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password ?? ""}")); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs index ee80f8b5..e23b2259 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs @@ -1,4 +1,5 @@ using Cleanuparr.Domain.Entities.HealthCheck; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Files; @@ -67,11 +68,14 @@ public partial class TransmissionService : TorrentDownloadService, IDeadTorrentC { UriBuilder uriBuilder = new(_downloadClientConfig.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/rpc"; + + // In Digest mode the handler's CredentialCache answers instead. + bool useBasicAuth = _downloadClientConfig.HttpAuthType == HttpAuthType.Basic; var client = new Client( _httpClient, uriBuilder.Uri.ToString(), - login: _downloadClientConfig.Username, - password: _downloadClientConfig.Password + login: useBasicAuth ? _downloadClientConfig.Username : null, + password: useBasicAuth ? _downloadClientConfig.Password : null ); _client = new TransmissionClientWrapper(client); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentHttpService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentHttpService.cs index 8fc16235..db6c82d9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentHttpService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentHttpService.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text; using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities.UTorrent.Request; +using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Persistence.Models.Configuration; using Microsoft.Extensions.Logging; @@ -51,10 +52,14 @@ public class UTorrentHttpService : IUTorrentHttpService var httpRequest = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri); httpRequest.Headers.Add("Cookie", guidCookie); - - var credentials = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}")); - httpRequest.Headers.Add("Authorization", $"Basic {credentials}"); + + // In Digest mode the handler's CredentialCache answers instead. + if (_config.HttpAuthType == HttpAuthType.Basic) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}")); + httpRequest.Headers.Add("Authorization", $"Basic {credentials}"); + } var response = await _httpClient.SendAsync(httpRequest); @@ -99,12 +104,16 @@ public class UTorrentHttpService : IUTorrentHttpService { UriBuilder uriBuilder = new UriBuilder(_config.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/token.html"; - - var credentials = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}")); var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri); - request.Headers.Add("Authorization", $"Basic {credentials}"); + + // In Digest mode the handler's CredentialCache answers instead. + if (_config.HttpAuthType == HttpAuthType.Basic) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}")); + request.Headers.Add("Authorization", $"Basic {credentials}"); + } var response = await _httpClient.SendAsync(request); diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs index efa16116..55b6918e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs @@ -76,7 +76,10 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider clientType, retryConfig, httpConfig.HttpCertificateValidation, - httpConfig.HttpSendUserAgent + httpConfig.HttpSendUserAgent, + downloadClientConfig.HttpAuthType, + downloadClientConfig.GetHandlerHttpAuthCredential(), + new Uri(downloadClientConfig.Url.GetLeftPart(UriPartial.Authority)) ); // Create and configure the client diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/BasicAuthHttpMessageHandler.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/BasicAuthHttpMessageHandler.cs new file mode 100644 index 00000000..b549eb53 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/BasicAuthHttpMessageHandler.cs @@ -0,0 +1,27 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; + +/// +/// Sends Basic auth up front for qBittorrent, Deluge and SABnzbd behind a reverse proxy. +/// Skips requests that already carry an Authorization header. +/// +public sealed class BasicAuthHttpMessageHandler : DelegatingHandler +{ + private readonly string _headerValue; + + public BasicAuthHttpMessageHandler(NetworkCredential credential) + { + string raw = $"{credential.UserName}:{credential.Password}"; + _headerValue = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Authorization ??= new AuthenticationHeaderValue("Basic", _headerValue); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs index 366799e2..100e4155 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs @@ -1,4 +1,7 @@ using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Services; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.DependencyInjection; @@ -67,51 +70,62 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions(); - - switch (config.Type) + + bool ByPassValidation(HttpRequestMessage sender, X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors policy) => + certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy); + + HttpClientHandler handler = config.Type switch { - case HttpClientType.WithRetry: - builder.PrimaryHandler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (sender, certificate, chain, policy) => - certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy) - }; - break; + HttpClientType.Deluge => new HttpClientHandler + { + AllowAutoRedirect = config.AllowAutoRedirect, + UseCookies = true, + CookieContainer = new CookieContainer(), + AutomaticDecompression = config.AutomaticDecompression, + ServerCertificateCustomValidationCallback = ByPassValidation, + }, - case HttpClientType.Deluge: - builder.PrimaryHandler = new HttpClientHandler - { - AllowAutoRedirect = config.AllowAutoRedirect, - UseCookies = true, - CookieContainer = new CookieContainer(), - AutomaticDecompression = config.AutomaticDecompression, - ServerCertificateCustomValidationCallback = (sender, certificate, chain, policy) => - certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy), - }; - break; + // uTorrent's WebUI requires the GUID cookie+token pair set manually by UTorrentHttpService + // UseCookies=false prevents .NET's CookieContainer from injecting a competing Cookie header. + HttpClientType.UTorrent => new HttpClientHandler + { + UseCookies = false, + ServerCertificateCustomValidationCallback = ByPassValidation, + }, - case HttpClientType.UTorrent: - // uTorrent's WebUI requires the GUID cookie+token pair set manually by UTorrentHttpService - // UseCookies=false prevents .NET's CookieContainer from injecting a competing Cookie header. - builder.PrimaryHandler = new HttpClientHandler - { - UseCookies = false, - ServerCertificateCustomValidationCallback = (sender, certificate, chain, policy) => - certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy), - }; - break; + // WithRetry and Default use the same handler shape. + _ => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = ByPassValidation, + }, + }; - case HttpClientType.Default: - default: - // Use default handler with certificate validation - var defaultHandler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (sender, certificate, chain, policy) => - certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy) - }; - builder.PrimaryHandler = defaultHandler; - break; + ApplyHttpAuth(builder.AdditionalHandlers, handler, config); + + builder.PrimaryHandler = handler; + } + + /// + /// Digest goes on , Basic on a delegating handler. + /// + internal static void ApplyHttpAuth(ICollection additionalHandlers, HttpClientHandler handler, HttpClientConfig config) + { + if (config.HttpAuthCredential is null) + { + return; } + + if (config.HttpAuthType == HttpAuthType.Digest) + { + Uri baseUri = config.BaseUri ?? throw new InvalidOperationException("Digest authentication requires a base URI"); + + // The "Digest" key blocks a fallback to NTLM/Negotiate for this authority. + handler.Credentials = new CredentialCache { { baseUri, "Digest", config.HttpAuthCredential } }; + handler.PreAuthenticate = true; + return; + } + + additionalHandlers.Add(new BasicAuthHttpMessageHandler(config.HttpAuthCredential)); } private void AddRetryPolicy(HttpMessageHandlerBuilder builder, RetryConfig retryConfig) diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs index 0f7dc400..40161f82 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs @@ -82,7 +82,16 @@ public class DynamicHttpClientFactory : IDynamicHttpClientFactory RegisterConfiguration(clientName, config); } - public void RegisterDownloadClient(string clientName, int timeout, HttpClientType clientType, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent) + public void RegisterDownloadClient( + string clientName, + int timeout, + HttpClientType clientType, + RetryConfig retryConfig, + CertificateValidationType certificateType, + bool sendUserAgent, + HttpAuthType httpAuthType = HttpAuthType.Basic, + NetworkCredential? httpAuthCredential = null, + Uri? baseUri = null) { HttpClientConfig config = new() { @@ -91,7 +100,10 @@ public class DynamicHttpClientFactory : IDynamicHttpClientFactory Type = clientType, RetryConfig = retryConfig, CertificateValidationType = certificateType, - SendUserAgent = sendUserAgent + SendUserAgent = sendUserAgent, + HttpAuthType = httpAuthType, + HttpAuthCredential = httpAuthCredential, + BaseUri = baseUri, }; // Configure Deluge-specific settings if needed diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs index 4dbde47b..1d087ced 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfig.cs @@ -23,4 +23,19 @@ public class HttpClientConfig /// Whether requests from this client carry a User-Agent header. ///
public bool SendUserAgent { get; set; } + + /// + /// HTTP authentication scheme to answer a reverse proxy's challenge with. + /// + public HttpAuthType HttpAuthType { get; set; } = HttpAuthType.Basic; + + /// + /// Credential to send as HTTP auth, or null when this client has none configured. + /// + public NetworkCredential? HttpAuthCredential { get; set; } + + /// + /// Scheme, host and port of the client's URL, used as the Digest key. + /// + public Uri? BaseUri { get; set; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs index 14d80297..1ae35af6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs @@ -1,3 +1,4 @@ +using System.Net; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration.General; @@ -36,7 +37,16 @@ public interface IDynamicHttpClientFactory /// /// Registers a configuration for a download client /// - void RegisterDownloadClient(string clientName, int timeout, HttpClientType clientType, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent); + void RegisterDownloadClient( + string clientName, + int timeout, + HttpClientType clientType, + RetryConfig retryConfig, + CertificateValidationType certificateType, + bool sendUserAgent, + HttpAuthType httpAuthType = HttpAuthType.Basic, + NetworkCredential? httpAuthCredential = null, + Uri? baseUri = null); /// /// Registers a client that opts out of every handler setting and only carries the User-Agent. diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.Designer.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.Designer.cs new file mode 100644 index 00000000..5bef99a7 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.Designer.cs @@ -0,0 +1,2050 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20261012213733_AddDownloadClientHttpAuth")] + partial class AddDownloadClientHttpAuth + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("data") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("smallint") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("uuid") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("text") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("real") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("text") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("MaxStrikes") + .HasColumnType("integer") + .HasColumnName("max_strikes"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("boolean") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_dead_torrent_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_dead_torrent_configs_download_client_config_id"); + + b.ToTable("dead_torrent_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("boolean") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("integer") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("integer") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxInactiveDays") + .HasColumnType("double precision") + .HasColumnName("max_inactive_days"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.PrimitiveCollection>("Categories") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection>("IgnoredRootDirs") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("boolean") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .HasColumnType("text") + .HasColumnName("api_key"); + + b.Property("DownloadDirectorySource") + .HasColumnType("text") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("text") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("text") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("HttpAuthPassword") + .HasColumnType("text") + .HasColumnName("http_auth_password"); + + b.Property("HttpAuthType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("http_auth_type"); + + b.Property("HttpAuthUsername") + .HasColumnType("text") + .HasColumnName("http_auth_username"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("text") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConnectivityCheckEnabled") + .HasColumnType("boolean") + .HasColumnName("connectivity_check_enabled"); + + b.PrimitiveCollection>("ConnectivityCheckUrls") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("connectivity_check_urls"); + + b.Property("DisplaySupportBanner") + .HasColumnType("boolean") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("boolean") + .HasColumnName("dry_run"); + + b.Property("HistoryRetentionDays") + .HasColumnType("integer") + .HasColumnName("history_retention_days"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("text") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("integer") + .HasColumnName("http_max_retries"); + + b.Property("HttpSendUserAgent") + .HasColumnType("boolean") + .HasColumnName("http_send_user_agent"); + + b.Property("HttpTimeout") + .HasColumnType("integer") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("boolean") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("integer") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("boolean") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("boolean") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("boolean") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("integer") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("integer") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("text") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("integer") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("integer") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("integer") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("boolean") + .HasColumnName("delete_if_any_file_blocked"); + + b.Property("DeletePrivate") + .HasColumnType("boolean") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("boolean") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("boolean") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("boolean") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "LazyLibrarian", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.LazyLibrarian#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("lazy_librarian_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("lazy_librarian_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("lazy_librarian_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sportarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sportarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("sportarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sportarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("sportarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("boolean") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("boolean") + .HasColumnName("on_download_cleaned"); + + b.Property("OnDownloadStopped") + .HasColumnType("boolean") + .HasColumnName("on_download_stopped"); + + b.Property("OnFailedImportStrike") + .HasColumnType("boolean") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnForceImported") + .HasColumnType("boolean") + .HasColumnName("on_force_imported"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("boolean") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("boolean") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("boolean") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("boolean") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("boolean") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("server_url"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.PrimitiveCollection>("Topics") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("text") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("integer") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("integer") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("boolean") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("integer") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("boolean") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("boolean") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ChangeCategory") + .HasColumnType("boolean") + .HasColumnName("failed_import_change_category"); + + b1.Property("DeletePrivate") + .HasColumnType("boolean") + .HasColumnName("failed_import_delete_private"); + + b1.Property("ForceImport") + .HasColumnType("boolean") + .HasColumnName("failed_import_force_import"); + + b1.Property("ForceImportMaxTries") + .HasColumnType("integer") + .HasColumnName("failed_import_force_import_max_tries"); + + b1.Property("IgnorePrivate") + .HasColumnType("boolean") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("integer") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("boolean") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("boolean") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("boolean") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("text") + .HasColumnName("ignore_above_size"); + + b.Property("IgnoreWhileAltSpeedActive") + .HasColumnType("boolean") + .HasColumnName("ignore_while_alt_speed_active"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("integer") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("integer") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("double precision") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("integer") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("text") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("uuid") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("boolean") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("boolean") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("boolean") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("integer") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("integer") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("integer") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("text") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("uuid") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("boolean") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("integer") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("boolean") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("boolean") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("integer") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("boolean") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("integer") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("uuid") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("IgnoreStruckDownloads") + .HasColumnType("boolean") + .HasColumnName("ignore_struck_downloads"); + + b.Property("LastProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("integer") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("boolean") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection>("SkipTags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("integer") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("boolean") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("boolean") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("uuid") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dead_torrent_configs_download_clients_download_client_confi"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_confi"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_con"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_co"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_co"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.cs new file mode 100644 index 00000000..2fa8c973 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261012213733_AddDownloadClientHttpAuth.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Data +{ + /// + public partial class AddDownloadClientHttpAuth : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "http_auth_password", + schema: "data", + table: "download_clients", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "http_auth_type", + schema: "data", + table: "download_clients", + type: "text", + nullable: false, + defaultValue: "basic"); + + migrationBuilder.AddColumn( + name: "http_auth_username", + schema: "data", + table: "download_clients", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "http_auth_password", + schema: "data", + table: "download_clients"); + + migrationBuilder.DropColumn( + name: "http_auth_type", + schema: "data", + table: "download_clients"); + + migrationBuilder.DropColumn( + name: "http_auth_username", + schema: "data", + table: "download_clients"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs index 2edf5a49..f5e2ce9b 100644 --- a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs @@ -681,6 +681,19 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data .HasColumnType("text") .HasColumnName("host"); + b.Property("HttpAuthPassword") + .HasColumnType("text") + .HasColumnName("http_auth_password"); + + b.Property("HttpAuthType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("http_auth_type"); + + b.Property("HttpAuthUsername") + .HasColumnType("text") + .HasColumnName("http_auth_username"); + b.Property("Name") .IsRequired() .HasColumnType("text") diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.Designer.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.Designer.cs new file mode 100644 index 00000000..3ee4e89a --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.Designer.cs @@ -0,0 +1,2046 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20261012213725_AddDownloadClientHttpAuth")] + partial class AddDownloadClientHttpAuth + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_dead_torrent_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_dead_torrent_configs_download_client_config_id"); + + b.ToTable("dead_torrent_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("INTEGER") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("INTEGER") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxInactiveDays") + .HasColumnType("REAL") + .HasColumnName("max_inactive_days"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("HttpAuthPassword") + .HasColumnType("TEXT") + .HasColumnName("http_auth_password"); + + b.Property("HttpAuthType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_auth_type"); + + b.Property("HttpAuthUsername") + .HasColumnType("TEXT") + .HasColumnName("http_auth_username"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ConnectivityCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("connectivity_check_enabled"); + + b.PrimitiveCollection("ConnectivityCheckUrls") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("connectivity_check_urls"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER") + .HasColumnName("history_retention_days"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpSendUserAgent") + .HasColumnType("INTEGER") + .HasColumnName("http_send_user_agent"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("INTEGER") + .HasColumnName("delete_if_any_file_blocked"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "LazyLibrarian", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.LazyLibrarian#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lazy_librarian_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lazy_librarian_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lazy_librarian_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sportarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sportarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sportarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sportarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sportarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnDownloadStopped") + .HasColumnType("INTEGER") + .HasColumnName("on_download_stopped"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnForceImported") + .HasColumnType("INTEGER") + .HasColumnName("on_force_imported"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("INTEGER") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_change_category"); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("ForceImport") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_force_import"); + + b1.Property("ForceImportMaxTries") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_force_import_max_tries"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("IgnoreWhileAltSpeedActive") + .HasColumnType("INTEGER") + .HasColumnName("ignore_while_alt_speed_active"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreStruckDownloads") + .HasColumnType("INTEGER") + .HasColumnName("ignore_struck_downloads"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dead_torrent_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.cs new file mode 100644 index 00000000..315729bd --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261012213725_AddDownloadClientHttpAuth.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddDownloadClientHttpAuth : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "http_auth_password", + table: "download_clients", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "http_auth_type", + table: "download_clients", + type: "TEXT", + nullable: false, + defaultValue: "basic"); + + migrationBuilder.AddColumn( + name: "http_auth_username", + table: "download_clients", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "http_auth_password", + table: "download_clients"); + + migrationBuilder.DropColumn( + name: "http_auth_type", + table: "download_clients"); + + migrationBuilder.DropColumn( + name: "http_auth_username", + table: "download_clients"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs index 65c0a031..d65cc1b1 100644 --- a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs @@ -675,6 +675,19 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("host"); + b.Property("HttpAuthPassword") + .HasColumnType("TEXT") + .HasColumnName("http_auth_password"); + + b.Property("HttpAuthType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_auth_type"); + + b.Property("HttpAuthUsername") + .HasColumnType("TEXT") + .HasColumnName("http_auth_username"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT") diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs index bc484242..64e1a880 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs @@ -293,6 +293,217 @@ public sealed class DownloadClientConfigTests #endregion + #region Validate - HTTP Auth + + [Fact] + public void Validate_NewConfig_DefaultsHttpAuthTypeToBasic() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080") + }; + + config.HttpAuthType.ShouldBe(HttpAuthType.Basic); + } + + [Fact] + public void Validate_QBittorrentWithApiKeyAndHttpAuthUsername_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12", + HttpAuthUsername = "proxyuser" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("API key can't be combined with HTTP authentication. Use qBittorrent username/password instead."); + } + + [Fact] + public void Validate_SabnzbdWithApiKeyAndHttpAuthUsername_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My SABnzbd", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Host = new Uri("http://localhost:8080"), + ApiKey = "0123456789abcdef0123456789abcdef", + HttpAuthUsername = "proxyuser" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_QBittorrentWithApiKeyAndNoHttpAuthUsername_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12", + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_DigestWithoutUsername_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My rTorrent", + TypeName = DownloadClientTypeName.rTorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Digest, + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("HTTP Digest authentication requires a username"); + } + + [Fact] + public void Validate_DigestWithUsername_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My rTorrent", + TypeName = DownloadClientTypeName.rTorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Digest, + Username = "proxyuser", + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_DigestQBittorrentWithoutHttpAuthUsername_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Digest, + Username = "qbit-login-user", + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("HTTP Digest authentication requires a username"); + } + + [Fact] + public void Validate_DigestQBittorrentWithHttpAuthUsername_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Digest, + HttpAuthUsername = "proxyuser", + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region GetHandlerHttpAuthCredential + + [Fact] + public void GetHandlerHttpAuthCredential_BasicRTorrent_ReturnsNull() + { + var config = new DownloadClientConfig + { + Name = "My rTorrent", + TypeName = DownloadClientTypeName.rTorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Basic, + Username = "user", + Password = "pass", + }; + + config.GetHandlerHttpAuthCredential().ShouldBeNull(); + } + + [Fact] + public void GetHandlerHttpAuthCredential_DigestRTorrent_UsesUsernamePassword() + { + var config = new DownloadClientConfig + { + Name = "My rTorrent", + TypeName = DownloadClientTypeName.rTorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Digest, + Username = "user", + Password = "pass", + }; + + var credential = config.GetHandlerHttpAuthCredential(); + credential.ShouldNotBeNull(); + credential!.UserName.ShouldBe("user"); + credential.Password.ShouldBe("pass"); + } + + [Fact] + public void GetHandlerHttpAuthCredential_BasicQBittorrentWithHttpAuthUsername_UsesDedicatedFields() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Basic, + Username = "qbit-login", + Password = "qbit-pass", + HttpAuthUsername = "proxyuser", + HttpAuthPassword = "proxypass", + }; + + var credential = config.GetHandlerHttpAuthCredential(); + credential.ShouldNotBeNull(); + credential!.UserName.ShouldBe("proxyuser"); + credential.Password.ShouldBe("proxypass"); + } + + [Fact] + public void GetHandlerHttpAuthCredential_BasicQBittorrentWithoutHttpAuthUsername_ReturnsNull() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + HttpAuthType = HttpAuthType.Basic, + Username = "qbit-login", + Password = "qbit-pass", + }; + + config.GetHandlerHttpAuthCredential().ShouldBeNull(); + } + + #endregion + #region Url Property Tests [Fact] diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs index 3f4b100e..779264cc 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs @@ -42,4 +42,42 @@ public sealed class DownloadClientTypeNameExtensionsTests { DownloadClientTypeName.Transmission.SupportsApiKeyAndCredentials().ShouldBeFalse(); } + + [Theory] + [InlineData(DownloadClientTypeName.qBittorrent)] + [InlineData(DownloadClientTypeName.Deluge)] + [InlineData(DownloadClientTypeName.Sabnzbd)] + public void UsesDedicatedHttpAuthFields_AppLoginClients_IsTrue(DownloadClientTypeName typeName) + { + typeName.UsesDedicatedHttpAuthFields().ShouldBeTrue(); + typeName.SupportsAuthField(DownloadClientAuthField.HttpAuthUsername).ShouldBeTrue(); + typeName.SupportsAuthField(DownloadClientAuthField.HttpAuthPassword).ShouldBeTrue(); + } + + [Theory] + [InlineData(DownloadClientTypeName.Transmission)] + [InlineData(DownloadClientTypeName.uTorrent)] + [InlineData(DownloadClientTypeName.rTorrent)] + public void UsesDedicatedHttpAuthFields_ReuseClients_IsFalse(DownloadClientTypeName typeName) + { + typeName.UsesDedicatedHttpAuthFields().ShouldBeFalse(); + } + + [Fact] + public void ApiKeyConflictsWithHttpAuth_QBittorrent_IsTrue() + { + DownloadClientTypeName.qBittorrent.ApiKeyConflictsWithHttpAuth().ShouldBeTrue(); + } + + [Fact] + public void ApiKeyConflictsWithHttpAuth_Sabnzbd_IsFalse() + { + DownloadClientTypeName.Sabnzbd.ApiKeyConflictsWithHttpAuth().ShouldBeFalse(); + } + + [Fact] + public void ApiKeyConflictsWithHttpAuth_Transmission_IsFalse() + { + DownloadClientTypeName.Transmission.ApiKeyConflictsWithHttpAuth().ShouldBeFalse(); + } } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs index afb28b90..e43b0267 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs @@ -60,6 +60,23 @@ public sealed record DownloadClientConfig [SensitiveData] public string? ApiKey { get; set; } + /// + /// HTTP authentication scheme the client answers a reverse proxy's challenge with. + /// + public HttpAuthType HttpAuthType { get; set; } = HttpAuthType.Basic; + + /// + /// Reverse proxy username for qBittorrent, Deluge and SABnzbd. + /// Other clients reuse Username. + /// + public string? HttpAuthUsername { get; set; } + + /// + /// Reverse proxy password for qBittorrent, Deluge and SABnzbd. + /// + [SensitiveData] + public string? HttpAuthPassword { get; set; } + /// /// The base URL path component, used by clients like Transmission and Deluge /// @@ -126,5 +143,22 @@ public sealed record DownloadClientConfig { throw new CustomValidationException("Use either an API key or a username and password, not both"); } + + if (TypeName.ApiKeyConflictsWithHttpAuth() && !string.IsNullOrWhiteSpace(ApiKey) && + !string.IsNullOrWhiteSpace(HttpAuthUsername)) + { + throw new ValidationException( + "API key can't be combined with HTTP authentication. Use qBittorrent username/password instead."); + } + + if (HttpAuthType == HttpAuthType.Digest && string.IsNullOrWhiteSpace(DigestUsername)) + { + throw new ValidationException("HTTP Digest authentication requires a username"); + } } + + /// + /// Username sent for Digest auth. + /// + private string? DigestUsername => TypeName.UsesDedicatedHttpAuthFields() ? HttpAuthUsername : Username; } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfigExtensions.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfigExtensions.cs new file mode 100644 index 00000000..539c017f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfigExtensions.cs @@ -0,0 +1,31 @@ +using System.Net; +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Persistence.Models.Configuration; + +/// +/// Resolves the HTTP auth credential for a download client's handler. +/// +public static class DownloadClientConfigExtensions +{ + /// + /// The credential the HTTP handler sends, or null. + /// rTorrent, Transmission and uTorrent build their own Basic header, so Basic mode returns null for them. + /// + public static NetworkCredential? GetHandlerHttpAuthCredential(this DownloadClientConfig config) + { + bool usesDedicatedFields = config.TypeName.UsesDedicatedHttpAuthFields(); + + if (config.HttpAuthType == HttpAuthType.Basic && !usesDedicatedFields) + { + return null; + } + + string? username = usesDedicatedFields ? config.HttpAuthUsername : config.Username; + string? password = usesDedicatedFields ? config.HttpAuthPassword : config.Password; + + return string.IsNullOrWhiteSpace(username) && string.IsNullOrWhiteSpace(password) + ? null + : new NetworkCredential(username, password); + } +}