From 41dbbf155be07bb3ac449e35d5a4387c9198eb58 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 22 Aug 2026 22:18:49 +0300 Subject: [PATCH] Add option to send the User-Agent header on all HTTP requests (#750) --- .../General/GeneralConfigControllerTests.cs | 2 + .../DependencyInjection/MainDI.cs | 6 - .../Requests/UpdateGeneralConfigRequest.cs | 3 + .../DynamicHttpClientConfigurationTests.cs | 11 + .../Http/DynamicHttpClientFactoryTests.cs | 127 ++ .../Http/DynamicHttpClientProviderTests.cs | 155 ++ .../HttpClientConfigurationServiceTests.cs | 125 ++ .../Http/UserAgentHeaderPipelineTests.cs | 108 + .../Shared/AppUserAgentTests.cs | 22 + .../Http/DynamicHttpClientProvider.cs | 3 +- .../DynamicHttpClientConfiguration.cs | 21 +- .../DynamicHttpClientFactory.cs | 44 +- .../HttpClientConfig.cs | 25 +- .../HttpClientConfigurationService.cs | 15 +- .../DynamicHttpClientSystem/HttpClientType.cs | 18 + .../IDynamicHttpClientFactory.cs | 17 +- .../DynamicHttpClientSystem/RetryConfig.cs | 10 + .../Services/ConnectivityChecker.cs | 3 +- ...822111024_AddHttpSendUserAgent.Designer.cs | 1993 +++++++++++++++++ .../20260822111024_AddHttpSendUserAgent.cs | 31 + .../Data/DataContextModelSnapshot.cs | 4 + ...822111016_AddHttpSendUserAgent.Designer.cs | 1989 ++++++++++++++++ .../20260822111016_AddHttpSendUserAgent.cs | 29 + .../Data/DataContextModelSnapshot.cs | 4 + .../Configuration/General/GeneralConfig.cs | 5 + .../Cleanuparr.Shared/Helpers/AppUserAgent.cs | 22 + .../Cleanuparr.Shared/Helpers/Constants.cs | 2 + .../core/feature-badges/feature-registry.ts | 1 + .../core/services/documentation.service.ts | 1 + .../general/general-settings.component.html | 6 + .../general-settings.component.spec.ts | 3 + .../general/general-settings.component.ts | 4 + .../app/shared/models/general-config.model.ts | 1 + docs/docs/configuration/general/index.mdx | 10 + e2e/tests/general/http-user-agent.api.spec.ts | 85 + e2e/tests/helpers/mocks/wiremock-client.ts | 34 +- .../malware-blocker/malware-blocker.spec.ts | 2 +- .../queue-cleaner/fractional-sizeleft.spec.ts | 4 +- 38 files changed, 4878 insertions(+), 67 deletions(-) create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientFactoryTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/HttpClientConfigurationServiceTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/UserAgentHeaderPipelineTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Shared/AppUserAgentTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientType.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/RetryConfig.cs create mode 100644 code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.cs create mode 100644 code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.cs create mode 100644 code/backend/Cleanuparr.Shared/Helpers/AppUserAgent.cs create mode 100644 e2e/tests/general/http-user-agent.api.spec.ts diff --git a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs index a0988f17..7cd87df3 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs @@ -69,6 +69,7 @@ public class GeneralConfigControllerTests : IDisposable DryRun = false, HttpMaxRetries = 5, HttpTimeout = 60, + HttpSendUserAgent = true, StatusCheckEnabled = false, EncryptionKey = existing.EncryptionKey, IgnoredDownloads = new List { "ignored-item" }, @@ -88,6 +89,7 @@ public class GeneralConfigControllerTests : IDisposable saved.DisplaySupportBanner.ShouldBeFalse(); saved.HttpMaxRetries.ShouldBe((ushort)5); saved.HttpTimeout.ShouldBe((ushort)60); + saved.HttpSendUserAgent.ShouldBeTrue(); saved.StrikeInactivityWindowHours.ShouldBe((ushort)48); saved.IgnoredDownloads.ShouldContain("ignored-item"); } diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs index 33bccf89..24c0fa5f 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs @@ -75,12 +75,6 @@ public static class MainDI // Add the dynamic HTTP client provider that uses the new system services.AddSingleton(); - // Add HTTP client for Plex authentication - services.AddHttpClient(Constants.HttpClientPlexAuthName); - - // Add HTTP client for OIDC authentication - services.AddHttpClient(Constants.HttpClientOidcAuthName); - return services; } diff --git a/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs index ed59e29c..9122e5d2 100644 --- a/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs @@ -17,6 +17,8 @@ public sealed record UpdateGeneralConfigRequest public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled; + public bool HttpSendUserAgent { get; init; } + public bool StatusCheckEnabled { get; init; } = true; public string EncryptionKey { get; init; } = Guid.NewGuid().ToString(); @@ -42,6 +44,7 @@ public sealed record UpdateGeneralConfigRequest existingConfig.HttpMaxRetries = HttpMaxRetries; existingConfig.HttpTimeout = HttpTimeout; existingConfig.HttpCertificateValidation = HttpCertificateValidation; + existingConfig.HttpSendUserAgent = HttpSendUserAgent; existingConfig.StatusCheckEnabled = StatusCheckEnabled; existingConfig.EncryptionKey = EncryptionKey; existingConfig.IgnoredDownloads = IgnoredDownloads; diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs index 1e595e4f..84e80ea9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs @@ -1,5 +1,6 @@ using System.Net; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Shared.Helpers; using Shouldly; using Xunit; @@ -40,4 +41,14 @@ public sealed class DynamicHttpClientConfigurationTests DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: true).ShouldBeTrue(); } + + [Fact] + public void ApplyUserAgent_ShouldSetTheProductToken() + { + using HttpClient client = new(); + + DynamicHttpClientConfiguration.ApplyUserAgent(client); + + client.DefaultRequestHeaders.UserAgent.ToString().ShouldBe(AppUserAgent.Value); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientFactoryTests.cs new file mode 100644 index 00000000..44370ed6 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientFactoryTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Persistence.Models.Configuration.General; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +/// +/// Covers the configurations the factory stores, without going through the HTTP pipeline. +/// +public sealed class DynamicHttpClientFactoryTests +{ + private readonly IHttpClientConfigStore _store = new HttpClientConfigStore(); + private readonly DynamicHttpClientFactory _factory; + + public DynamicHttpClientFactoryTests() + { + IHttpClientFactory httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient()); + + _factory = new DynamicHttpClientFactory( + httpClientFactory, + _store, + new OptionsCache(), + NullLogger.Instance); + } + + [Fact] + public void CreateClient_ShouldThrow_WhenTheNameWasNeverRegistered() + { + Should.Throw(() => _factory.CreateClient("unknown")); + } + + [Fact] + public void RegisterDelugeClient_ShouldAllowRedirectsAndDecompression() + { + _factory.RegisterDelugeClient("deluge", timeout: 30, new RetryConfig { MaxRetries = 2 }, + CertificateValidationType.Enabled, sendUserAgent: true); + + HttpClientConfig config = Stored("deluge"); + config.Type.ShouldBe(HttpClientType.Deluge); + config.AllowAutoRedirect.ShouldBeTrue(); + config.AutomaticDecompression.ShouldBe(DecompressionMethods.GZip | DecompressionMethods.Deflate); + config.SendUserAgent.ShouldBeTrue(); + } + + [Theory] + [InlineData(HttpClientType.Deluge)] + [InlineData(HttpClientType.UTorrent)] + [InlineData(HttpClientType.WithRetry)] + public void RegisterDownloadClient_ShouldStoreTheRequestedType(HttpClientType clientType) + { + _factory.RegisterDownloadClient("client", timeout: 30, clientType, new RetryConfig { MaxRetries = 1 }, + CertificateValidationType.Enabled, sendUserAgent: true); + + HttpClientConfig config = Stored("client"); + config.Type.ShouldBe(clientType); + config.Timeout.ShouldBe(30); + config.RetryConfig!.MaxRetries.ShouldBe(1); + config.SendUserAgent.ShouldBeTrue(); + } + + [Fact] + public void RegisterPlainClient_ShouldStoreNoRetryPolicy() + { + _factory.RegisterPlainClient("plain", sendUserAgent: true); + + HttpClientConfig config = Stored("plain"); + config.Type.ShouldBe(HttpClientType.Plain); + config.RetryConfig.ShouldBeNull(); + config.SendUserAgent.ShouldBeTrue(); + } + + [Fact] + public void UnregisterConfiguration_ShouldDropTheStoredConfiguration() + { + _factory.RegisterPlainClient("plain", sendUserAgent: true); + + _factory.UnregisterConfiguration("plain"); + + _store.TryGetConfiguration("plain", out _).ShouldBeFalse(); + } + + [Fact] + public void UpdateAllClientsFromGeneralConfig_ShouldRefreshEveryStoredClient() + { + _factory.RegisterRetryClient("retry", timeout: 1, new RetryConfig { MaxRetries = 0 }, + CertificateValidationType.Enabled, sendUserAgent: false); + _factory.RegisterPlainClient("plain", sendUserAgent: false); + + _factory.UpdateAllClientsFromGeneralConfig(new GeneralConfig + { + HttpTimeout = 55, + HttpMaxRetries = 9, + HttpCertificateValidation = CertificateValidationType.Disabled, + HttpSendUserAgent = true, + }); + + HttpClientConfig retry = Stored("retry"); + retry.Timeout.ShouldBe(55); + retry.CertificateValidationType.ShouldBe(CertificateValidationType.Disabled); + retry.RetryConfig!.MaxRetries.ShouldBe(9); + retry.SendUserAgent.ShouldBeTrue(); + + Stored("plain").SendUserAgent.ShouldBeTrue(); + } + + [Fact] + public void UpdateAllClientsFromGeneralConfig_ShouldDoNothing_WhenNothingIsRegistered() + { + Should.NotThrow(() => _factory.UpdateAllClientsFromGeneralConfig(new GeneralConfig())); + + _store.GetAllConfigurations().ShouldBeEmpty(); + } + + private HttpClientConfig Stored(string clientName) + { + _store.TryGetConfiguration(clientName, out HttpClientConfig? config).ShouldBeTrue(); + return config!; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs new file mode 100644 index 00000000..94cd1441 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs @@ -0,0 +1,155 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Http; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.General; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +/// +/// Covers the second registration site for the general HTTP settings. +/// +public sealed class DynamicHttpClientProviderTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private readonly IDynamicHttpClientFactory _clientFactory = Substitute.For(); + + public DynamicHttpClientProviderTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using DataContext context = new(_options); + context.Database.EnsureCreated(); + + _clientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient()); + } + + [Theory] + [InlineData(DownloadClientTypeName.Deluge, HttpClientType.Deluge)] + [InlineData(DownloadClientTypeName.uTorrent, HttpClientType.UTorrent)] + [InlineData(DownloadClientTypeName.qBittorrent, HttpClientType.WithRetry)] + [InlineData(DownloadClientTypeName.Transmission, HttpClientType.WithRetry)] + [InlineData(DownloadClientTypeName.rTorrent, HttpClientType.WithRetry)] + public void CreateClient_ShouldMapTheDownloadClientType(DownloadClientTypeName typeName, HttpClientType expected) + { + Seed(sendUserAgent: true); + DownloadClientConfig config = BuildConfig(typeName); + + using HttpClient client = BuildProvider().CreateClient(config); + + _clientFactory.Received(1).RegisterDownloadClient( + $"DownloadClient_{config.Id}", + Arg.Any(), + expected, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateClient_ShouldPassTheUserAgentSetting(bool sendUserAgent) + { + Seed(sendUserAgent: sendUserAgent); + + using HttpClient client = BuildProvider().CreateClient(BuildConfig(DownloadClientTypeName.qBittorrent)); + + _clientFactory.Received(1).RegisterDownloadClient( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + sendUserAgent); + } + + [Fact] + public void CreateClient_ShouldCarryTheStoredHttpSettings() + { + Seed(sendUserAgent: false, timeout: 33, maxRetries: 4, certificate: CertificateValidationType.DisabledForLocalAddresses); + + using HttpClient client = BuildProvider().CreateClient(BuildConfig(DownloadClientTypeName.qBittorrent)); + + _clientFactory.Received(1).RegisterDownloadClient( + Arg.Any(), + 33, + Arg.Any(), + Arg.Is(retry => retry.MaxRetries == 4 && retry.ExcludeUnauthorized), + CertificateValidationType.DisabledForLocalAddresses, + false); + } + + [Fact] + public void CreateClient_ShouldSetTheBaseAddress() + { + Seed(sendUserAgent: false); + + using HttpClient client = BuildProvider().CreateClient(BuildConfig(DownloadClientTypeName.qBittorrent)); + + client.BaseAddress.ShouldNotBeNull(); + client.BaseAddress!.ToString().ShouldStartWith("http://localhost:8080/"); + } + + private DynamicHttpClientProvider BuildProvider() + { + ServiceCollection services = new(); + services.AddScoped(_ => new DataContext(_options)); + + return new DynamicHttpClientProvider( + NullLogger.Instance, + services.BuildServiceProvider().GetRequiredService(), + _clientFactory); + } + + private static DownloadClientConfig BuildConfig(DownloadClientTypeName typeName) => new() + { + Id = Guid.NewGuid(), + Name = $"test-{typeName}", + TypeName = typeName, + Type = DownloadClientType.Torrent, + Enabled = true, + Host = new Uri("http://localhost:8080"), + }; + + private void Seed( + bool sendUserAgent, + ushort timeout = 100, + ushort maxRetries = 0, + CertificateValidationType certificate = CertificateValidationType.Enabled) + { + using DataContext context = new(_options); + + context.GeneralConfigs.RemoveRange(context.GeneralConfigs); + context.GeneralConfigs.Add(new GeneralConfig + { + Id = Guid.NewGuid(), + HttpSendUserAgent = sendUserAgent, + HttpTimeout = timeout, + HttpMaxRetries = maxRetries, + HttpCertificateValidation = certificate, + IgnoredDownloads = [], + Log = new LoggingConfig() + }); + context.SaveChanges(); + } + + public void Dispose() + { + _connection.Dispose(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/HttpClientConfigurationServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/HttpClientConfigurationServiceTests.cs new file mode 100644 index 00000000..31a694a6 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/HttpClientConfigurationServiceTests.cs @@ -0,0 +1,125 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Shared.Helpers; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +/// +/// Covers the startup registrations that put every client in reach of the general HTTP settings. +/// +public sealed class HttpClientConfigurationServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private readonly IDynamicHttpClientFactory _clientFactory = Substitute.For(); + + public HttpClientConfigurationServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using DataContext context = new(_options); + context.Database.EnsureCreated(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartAsync_ShouldPassTheUserAgentSetting_ToEveryRegistration(bool sendUserAgent) + { + Seed(sendUserAgent: sendUserAgent); + + await BuildService().StartAsync(CancellationToken.None); + + _clientFactory.Received(1).RegisterRetryClient( + Constants.HttpClientWithRetryName, + Arg.Any(), + Arg.Any(), + Arg.Any(), + sendUserAgent); + + _clientFactory.Received(1).RegisterDelugeClient( + nameof(DelugeService), + Arg.Any(), + Arg.Any(), + Arg.Any(), + sendUserAgent); + + _clientFactory.Received(1).RegisterPlainClient(Constants.HttpClientPlexAuthName, sendUserAgent); + _clientFactory.Received(1).RegisterPlainClient(Constants.HttpClientOidcAuthName, sendUserAgent); + _clientFactory.Received(1).RegisterPlainClient(Constants.HttpClientConnectivityName, sendUserAgent); + } + + [Fact] + public async Task StartAsync_ShouldCarryTheStoredHttpSettings_IntoTheRetryClient() + { + Seed(sendUserAgent: true, timeout: 42, maxRetries: 7, certificate: CertificateValidationType.Disabled); + + await BuildService().StartAsync(CancellationToken.None); + + _clientFactory.Received(1).RegisterRetryClient( + Constants.HttpClientWithRetryName, + 42, + Arg.Is(retry => retry.MaxRetries == 7 && retry.ExcludeUnauthorized), + CertificateValidationType.Disabled, + true); + } + + [Fact] + public async Task StartAsync_ShouldThrow_WhenNoGeneralConfigExists() + { + await Should.ThrowAsync( + () => BuildService().StartAsync(CancellationToken.None)); + } + + private HttpClientConfigurationService BuildService() + { + ServiceCollection services = new(); + services.AddScoped(_ => new DataContext(_options)); + + return new HttpClientConfigurationService( + _clientFactory, + NullLogger.Instance, + services.BuildServiceProvider().GetRequiredService()); + } + + private void Seed( + bool sendUserAgent, + ushort timeout = 100, + ushort maxRetries = 0, + CertificateValidationType certificate = CertificateValidationType.Enabled) + { + using DataContext context = new(_options); + + context.GeneralConfigs.Add(new GeneralConfig + { + Id = Guid.NewGuid(), + HttpSendUserAgent = sendUserAgent, + HttpTimeout = timeout, + HttpMaxRetries = maxRetries, + HttpCertificateValidation = certificate, + IgnoredDownloads = [], + Log = new LoggingConfig() + }); + context.SaveChanges(); + } + + public void Dispose() + { + _connection.Dispose(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/UserAgentHeaderPipelineTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/UserAgentHeaderPipelineTests.cs new file mode 100644 index 00000000..aaaca07e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/UserAgentHeaderPipelineTests.cs @@ -0,0 +1,108 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Infrastructure.Services; +using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Shared.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +/// +/// Drives the real IHttpClientFactory pipeline. +/// Covers both the handler-configured clients and the plain ones. +/// +public sealed class UserAgentHeaderPipelineTests +{ + [Fact] + public void RetryClient_ShouldCarryTheUserAgent_WhenTheSettingIsOn() + { + using ServiceProvider provider = BuildProvider(); + IDynamicHttpClientFactory dynamicFactory = provider.GetRequiredService(); + RegisterRetryClient(dynamicFactory, sendUserAgent: true); + + using HttpClient client = dynamicFactory.CreateClient(Constants.HttpClientWithRetryName); + + client.DefaultRequestHeaders.UserAgent.ToString().ShouldBe(AppUserAgent.Value); + } + + [Fact] + public void PlainClient_ShouldCarryTheUserAgent_WhenTheSettingIsOn() + { + using ServiceProvider provider = BuildProvider(); + IDynamicHttpClientFactory dynamicFactory = provider.GetRequiredService(); + dynamicFactory.RegisterPlainClient(Constants.HttpClientOidcAuthName, sendUserAgent: true); + + using HttpClient client = dynamicFactory.CreateClient(Constants.HttpClientOidcAuthName); + + client.DefaultRequestHeaders.UserAgent.ToString().ShouldBe(AppUserAgent.Value); + } + + [Fact] + public void PlainClient_ShouldKeepTheDefaultTimeout() + { + using ServiceProvider provider = BuildProvider(); + IDynamicHttpClientFactory dynamicFactory = provider.GetRequiredService(); + dynamicFactory.RegisterPlainClient(Constants.HttpClientOidcAuthName, sendUserAgent: true); + + using HttpClient client = dynamicFactory.CreateClient(Constants.HttpClientOidcAuthName); + + client.Timeout.ShouldBe(TimeSpan.FromSeconds(100)); + } + + [Fact] + public void NoClient_ShouldCarryAUserAgent_WhenTheSettingIsOff() + { + using ServiceProvider provider = BuildProvider(); + IDynamicHttpClientFactory dynamicFactory = provider.GetRequiredService(); + RegisterRetryClient(dynamicFactory, sendUserAgent: false); + dynamicFactory.RegisterPlainClient(Constants.HttpClientOidcAuthName, sendUserAgent: false); + + using HttpClient retry = dynamicFactory.CreateClient(Constants.HttpClientWithRetryName); + using HttpClient plain = dynamicFactory.CreateClient(Constants.HttpClientOidcAuthName); + + retry.DefaultRequestHeaders.UserAgent.ShouldBeEmpty(); + plain.DefaultRequestHeaders.UserAgent.ShouldBeEmpty(); + } + + [Fact] + public void PlainClient_ShouldPickUpTheSetting_WithoutARestart() + { + using ServiceProvider provider = BuildProvider(); + IDynamicHttpClientFactory dynamicFactory = provider.GetRequiredService(); + dynamicFactory.RegisterPlainClient(Constants.HttpClientOidcAuthName, sendUserAgent: false); + + using HttpClient before = dynamicFactory.CreateClient(Constants.HttpClientOidcAuthName); + before.DefaultRequestHeaders.UserAgent.ShouldBeEmpty(); + + dynamicFactory.UpdateAllClientsFromGeneralConfig(new GeneralConfig { HttpSendUserAgent = true }); + + using HttpClient after = dynamicFactory.CreateClient(Constants.HttpClientOidcAuthName); + + after.DefaultRequestHeaders.UserAgent.ToString().ShouldBe(AppUserAgent.Value); + + // The setting reaches the next client, never one already handed out. + // Every consumer is scoped or disposes per call, so none outlives a change. + before.DefaultRequestHeaders.UserAgent.ShouldBeEmpty(); + } + + private static void RegisterRetryClient(IDynamicHttpClientFactory dynamicFactory, bool sendUserAgent) => + dynamicFactory.RegisterRetryClient( + Constants.HttpClientWithRetryName, + timeout: 100, + new RetryConfig { MaxRetries = 0 }, + CertificateValidationType.Enabled, + sendUserAgent); + + private static ServiceProvider BuildProvider() + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(); + services.AddDynamicHttpClients(); + + return services.BuildServiceProvider(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Shared/AppUserAgentTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Shared/AppUserAgentTests.cs new file mode 100644 index 00000000..de25d8fb --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Shared/AppUserAgentTests.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Shared.Helpers; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Shared; + +public sealed class AppUserAgentTests +{ + [Theory] + [InlineData(1, 2, 3, 4)] + [InlineData(1, 2, 3, 0)] + public void Format_ShouldDropTheRevision(int major, int minor, int build, int revision) + { + AppUserAgent.Format(new Version(major, minor, build, revision)).ShouldBe("Cleanuparr/1.2.3"); + } + + [Fact] + public void Format_ShouldEmitTheBareProductToken_WhenTheVersionIsUnknown() + { + AppUserAgent.Format(null).ShouldBe("Cleanuparr"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs index 554c817f..efa16116 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientProvider.cs @@ -75,7 +75,8 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider httpConfig.HttpTimeout, clientType, retryConfig, - httpConfig.HttpCertificateValidation + httpConfig.HttpCertificateValidation, + httpConfig.HttpSendUserAgent ); // Create and configure the client diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs index f6635d0a..366799e2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs @@ -1,5 +1,6 @@ using System.Net; using Cleanuparr.Infrastructure.Services; +using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; using Microsoft.Extensions.Options; @@ -24,10 +25,20 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions(); - + if (!configStore.TryGetConfiguration(name, out HttpClientConfig? config)) return; + if (config.SendUserAgent) + { + options.HttpClientActions.Add(ApplyUserAgent); + } + + if (config.Type is HttpClientType.Plain) + { + return; + } + // Configure the HttpClient options.HttpClientActions.Add(httpClient => { @@ -47,6 +58,11 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions -/// Retry configuration for HTTP clients -/// -public class RetryConfig -{ - public int MaxRetries { get; set; } - public bool ExcludeUnauthorized { get; set; } = true; + /// + /// Whether requests from this client carry a User-Agent header. + /// + public bool SendUserAgent { get; set; } } - -/// -/// Types of HTTP clients that can be configured -/// -public enum HttpClientType -{ - Default, - WithRetry, - Deluge, - UTorrent, -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs index 28c85cb9..6cdc2f43 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientConfigurationService.cs @@ -47,7 +47,8 @@ public class HttpClientConfigurationService : IHostedService MaxRetries = config.HttpMaxRetries, ExcludeUnauthorized = true }, - config.HttpCertificateValidation + config.HttpCertificateValidation, + config.HttpSendUserAgent ); // Register the Deluge client @@ -59,9 +60,17 @@ public class HttpClientConfigurationService : IHostedService MaxRetries = config.HttpMaxRetries, ExcludeUnauthorized = true }, - config.HttpCertificateValidation + config.HttpCertificateValidation, + config.HttpSendUserAgent ); - + + // These keep the handler defaults they had before the dynamic system existed. + // Registering them only puts them in reach of the User-Agent setting. + _clientFactory.RegisterPlainClient(Constants.HttpClientPlexAuthName, config.HttpSendUserAgent); + _clientFactory.RegisterPlainClient(Constants.HttpClientOidcAuthName, config.HttpSendUserAgent); + _clientFactory.RegisterPlainClient(Constants.HttpClientConnectivityName, config.HttpSendUserAgent); + + _logger.LogInformation("Pre-registered standard HTTP client configurations"); } catch (Exception ex) diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientType.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientType.cs new file mode 100644 index 00000000..10a5eada --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientType.cs @@ -0,0 +1,18 @@ +namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; + +/// +/// Types of HTTP clients that can be configured +/// +public enum HttpClientType +{ + Default, + WithRetry, + Deluge, + UTorrent, + + /// + /// Carries the User-Agent and nothing else. + /// No primary handler, no timeout override, no retry policy. + /// + Plain, +} diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs index 560861d0..14d80297 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs @@ -26,18 +26,23 @@ public interface IDynamicHttpClientFactory /// /// Registers a retry-enabled HttpClient configuration /// - void RegisterRetryClient(string clientName, int timeout, RetryConfig retryConfig, CertificateValidationType certificateType); - + void RegisterRetryClient(string clientName, int timeout, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent); + /// /// Registers a Deluge-specific HttpClient configuration /// - void RegisterDelugeClient(string clientName, int timeout, RetryConfig retryConfig, CertificateValidationType certificateType); - + void RegisterDelugeClient(string clientName, int timeout, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent); + /// /// Registers a configuration for a download client /// - void RegisterDownloadClient(string clientName, int timeout, HttpClientType clientType, RetryConfig retryConfig, CertificateValidationType certificateType); - + void RegisterDownloadClient(string clientName, int timeout, HttpClientType clientType, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent); + + /// + /// Registers a client that opts out of every handler setting and only carries the User-Agent. + /// + void RegisterPlainClient(string clientName, bool sendUserAgent); + /// /// Unregisters a configuration /// diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/RetryConfig.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/RetryConfig.cs new file mode 100644 index 00000000..6d023c89 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/RetryConfig.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; + +/// +/// Retry configuration for HTTP clients +/// +public class RetryConfig +{ + public int MaxRetries { get; set; } + public bool ExcludeUnauthorized { get; set; } = true; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Services/ConnectivityChecker.cs b/code/backend/Cleanuparr.Infrastructure/Services/ConnectivityChecker.cs index 48d75368..59ce5e0f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/ConnectivityChecker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/ConnectivityChecker.cs @@ -1,5 +1,6 @@ using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Services; @@ -24,7 +25,7 @@ public sealed class ConnectivityChecker : IConnectivityChecker return true; } - using HttpClient client = _httpClientFactory.CreateClient(); + using HttpClient client = _httpClientFactory.CreateClient(Constants.HttpClientConnectivityName); using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(ProbeTimeout); diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.Designer.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.Designer.cs new file mode 100644 index 00000000..4665da73 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.Designer.cs @@ -0,0 +1,1993 @@ +// +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("20260822111024_AddHttpSendUserAgent")] + partial class AddHttpSendUserAgent + { + /// + 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("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("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("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("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("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("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("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("EncryptionKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("encryption_key"); + + 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("OnFailedImportStrike") + .HasColumnType("boolean") + .HasColumnName("on_failed_import_strike"); + + 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("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("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/20260822111024_AddHttpSendUserAgent.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.cs new file mode 100644 index 00000000..c3dd9f91 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20260822111024_AddHttpSendUserAgent.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Data +{ + /// + public partial class AddHttpSendUserAgent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "http_send_user_agent", + schema: "data", + table: "general_configs", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "http_send_user_agent", + schema: "data", + table: "general_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs index 61866de7..d160cd72 100644 --- a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs @@ -727,6 +727,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data .HasColumnType("integer") .HasColumnName("http_max_retries"); + b.Property("HttpSendUserAgent") + .HasColumnType("boolean") + .HasColumnName("http_send_user_agent"); + b.Property("HttpTimeout") .HasColumnType("integer") .HasColumnName("http_timeout"); diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.Designer.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.Designer.cs new file mode 100644 index 00000000..2bc0c32e --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.Designer.cs @@ -0,0 +1,1989 @@ +// +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("20260822111016_AddHttpSendUserAgent")] + partial class AddHttpSendUserAgent + { + /// + 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("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("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("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("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("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("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("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("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + 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("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + 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("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("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/20260822111016_AddHttpSendUserAgent.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.cs new file mode 100644 index 00000000..b104a86d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20260822111016_AddHttpSendUserAgent.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddHttpSendUserAgent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "http_send_user_agent", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "http_send_user_agent", + table: "general_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs index 676cd216..994d8994 100644 --- a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs @@ -721,6 +721,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("INTEGER") .HasColumnName("http_max_retries"); + b.Property("HttpSendUserAgent") + .HasColumnType("INTEGER") + .HasColumnName("http_send_user_agent"); + b.Property("HttpTimeout") .HasColumnType("INTEGER") .HasColumnName("http_timeout"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs index 68a96c41..4d4274dc 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs @@ -22,6 +22,11 @@ public sealed record GeneralConfig : IConfig public CertificateValidationType HttpCertificateValidation { get; set; } = CertificateValidationType.Enabled; + /// + /// Whether outbound requests carry a User-Agent header. + /// + public bool HttpSendUserAgent { get; set; } + public bool StatusCheckEnabled { get; set; } = true; diff --git a/code/backend/Cleanuparr.Shared/Helpers/AppUserAgent.cs b/code/backend/Cleanuparr.Shared/Helpers/AppUserAgent.cs new file mode 100644 index 00000000..e43e9490 --- /dev/null +++ b/code/backend/Cleanuparr.Shared/Helpers/AppUserAgent.cs @@ -0,0 +1,22 @@ +using System.Reflection; + +namespace Cleanuparr.Shared.Helpers; + +/// +/// The User-Agent product token, shaped per RFC 9110. +/// +public static class AppUserAgent +{ + private const string ProductName = "Cleanuparr"; + + /// + /// The User-Agent value for the running build. + /// Reads "Cleanuparr/1.2.3" on a tagged release. + /// + public static readonly string Value = Format(Assembly.GetEntryAssembly()?.GetName().Version); + + internal static string Format(Version? version) => + version is null + ? ProductName + : $"{ProductName}/{version.Major}.{version.Minor}.{Math.Max(version.Build, 0)}"; +} diff --git a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs index 2619e62b..02df0eae 100644 --- a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs +++ b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs @@ -14,6 +14,8 @@ public static class Constants public const string HttpClientOidcAuthName = "OidcAuth"; + public const string HttpClientConnectivityName = "Connectivity"; + public static readonly MemoryCacheEntryOptions DefaultCacheEntryOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(10) diff --git a/code/frontend/src/app/core/feature-badges/feature-registry.ts b/code/frontend/src/app/core/feature-badges/feature-registry.ts index f11b34b9..3ee8a3c9 100644 --- a/code/frontend/src/app/core/feature-badges/feature-registry.ts +++ b/code/frontend/src/app/core/feature-badges/feature-registry.ts @@ -44,4 +44,5 @@ export const NEW_FEATURES: NewFeature[] = [ { id: 'ignore-alt-speed' }, { id: 'sportarr-blocklist' }, { id: 'lazylibrarian-blocklist' }, + { id: 'http-user-agent' }, ]; diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 9ed5147b..a9a6b2c2 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -50,6 +50,7 @@ export class DocumentationService { 'httpMaxRetries': 'http-max-retries', 'httpTimeout': 'http-timeout', 'httpCertificateValidation': 'http-certificate-validation', + 'httpSendUserAgent': 'http-user-agent', 'statusCheckEnabled': 'status-check', 'log.level': 'log-level', 'log.rollingSizeMB': 'rolling-size-mb', diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.html b/code/frontend/src/app/features/settings/general/general-settings.component.html index db04f89d..917b38a9 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.html +++ b/code/frontend/src/app/features/settings/general/general-settings.component.html @@ -92,6 +92,12 @@ hint="Enable or disable certificate validation for HTTPS requests" helpKey="general:httpCertificateValidation" /> + +
+ + diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts b/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts index a64a37c7..0ca77576 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts +++ b/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts @@ -14,6 +14,7 @@ const CONFIG: GeneralConfig = { httpMaxRetries: 2, httpTimeout: 45, httpCertificateValidation: CertificateValidationType.DisabledForLocalAddresses, + httpSendUserAgent: true, statusCheckEnabled: false, strikeInactivityWindowHours: 12, historyRetentionDays: 90, @@ -140,6 +141,7 @@ describe('GeneralSettingsComponent', () => { expect(component.genForm.ignoredDownloads().value()).toEqual([]); expect(component.genForm.connectivityCheckEnabled().value()).toBe(false); expect(component.genForm.connectivityCheckUrls().value()).toEqual([]); + expect(component.genForm.httpSendUserAgent().value()).toBe(false); expect(component.dirty()).toBe(false); }); @@ -161,6 +163,7 @@ describe('GeneralSettingsComponent', () => { httpMaxRetries: 2, httpTimeout: 60, httpCertificateValidation: CertificateValidationType.DisabledForLocalAddresses, + httpSendUserAgent: true, statusCheckEnabled: false, strikeInactivityWindowHours: 12, historyRetentionDays: 90, diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.ts b/code/frontend/src/app/features/settings/general/general-settings.component.ts index 659fc68b..24bb2110 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.ts +++ b/code/frontend/src/app/features/settings/general/general-settings.component.ts @@ -37,6 +37,7 @@ interface GeneralSettingsFormModel { httpMaxRetries: number | null; httpTimeout: number | null; httpCertificateValidation: CertificateValidationType; + httpSendUserAgent: boolean; statusCheckEnabled: boolean; ignoredDownloads: string[]; connectivityCheckEnabled: boolean; @@ -96,6 +97,7 @@ export class GeneralSettingsComponent implements HasPendingChanges { httpMaxRetries: 3, httpTimeout: 30, httpCertificateValidation: CertificateValidationType.Enabled, + httpSendUserAgent: false, statusCheckEnabled: true, ignoredDownloads: [], connectivityCheckEnabled: false, @@ -185,6 +187,7 @@ export class GeneralSettingsComponent implements HasPendingChanges { httpMaxRetries: config.httpMaxRetries, httpTimeout: config.httpTimeout, httpCertificateValidation: config.httpCertificateValidation, + httpSendUserAgent: config.httpSendUserAgent ?? false, statusCheckEnabled: config.statusCheckEnabled, ignoredDownloads: config.ignoredDownloads ?? [], connectivityCheckEnabled: config.connectivityCheckEnabled ?? false, @@ -233,6 +236,7 @@ export class GeneralSettingsComponent implements HasPendingChanges { httpMaxRetries: m.httpMaxRetries ?? 3, httpTimeout: m.httpTimeout ?? 30, httpCertificateValidation: m.httpCertificateValidation as CertificateValidationType, + httpSendUserAgent: m.httpSendUserAgent, statusCheckEnabled: m.statusCheckEnabled, strikeInactivityWindowHours: m.strikeInactivityWindowHours ?? 24, historyRetentionDays: m.historyRetentionDays ?? 365, diff --git a/code/frontend/src/app/shared/models/general-config.model.ts b/code/frontend/src/app/shared/models/general-config.model.ts index 58e7bcb3..f7c65ca4 100644 --- a/code/frontend/src/app/shared/models/general-config.model.ts +++ b/code/frontend/src/app/shared/models/general-config.model.ts @@ -22,6 +22,7 @@ export interface GeneralConfig { httpMaxRetries: number; httpTimeout: number; httpCertificateValidation: CertificateValidationType; + httpSendUserAgent: boolean; statusCheckEnabled: boolean; strikeInactivityWindowHours: number; historyRetentionDays: number; diff --git a/docs/docs/configuration/general/index.mdx b/docs/docs/configuration/general/index.mdx index 7b78de23..566a4456 100644 --- a/docs/docs/configuration/general/index.mdx +++ b/docs/docs/configuration/general/index.mdx @@ -138,6 +138,16 @@ SSL certificate validation for HTTPS connections. + + +Cleanuparr sends no `User-Agent` header by default. Enable this setting to identify requests coming from it. + +The header contains the name and the version of the app, for example `Cleanuparr/2.0.0`. It applies to every outgoing request. + + +
diff --git a/e2e/tests/general/http-user-agent.api.spec.ts b/e2e/tests/general/http-user-agent.api.spec.ts new file mode 100644 index 00000000..9e83ae9e --- /dev/null +++ b/e2e/tests/general/http-user-agent.api.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { buildDownloadClientPayload } from '../helpers/api/download-client'; +import { ArrStubs, DownloadClientStubs } from '../helpers/mocks'; +import type { LoggedRequest } from '../helpers/mocks'; + +const USER_AGENT = /^Cleanuparr(\/\d+\.\d+\.\d+)?$/; + +function userAgentOf(request: LoggedRequest): string { + const match = Object.entries(request.headers).find( + ([name]) => name.toLowerCase() === 'user-agent', + ); + if (!match) { + return ''; + } + const value = match[1]; + return Array.isArray(value) ? value.join(', ') : value; +} + +test.describe('HTTP user agent', () => { + test.afterEach(async ({ api }) => { + await api.general.patch({ httpSendUserAgent: false }); + }); + + test('arr requests carry the user agent when the setting is on', async ({ api, mocks }) => { + await api.general.patch({ httpSendUserAgent: true }); + await mocks.arr.stub(ArrStubs.arrHealthStub()); + await mocks.arr.resetRequests(); + + const res = await api.arr.testInstance('sonarr', { + name: 'ua-arr', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'good-key', + version: 3, + }); + expect(res.ok).toBe(true); + + const entry = await mocks.arr.waitForRequest({ + method: 'GET', + urlPattern: '/api/v[0-9]+/system/status.*', + }); + expect(userAgentOf(entry)).toMatch(USER_AGENT); + }); + + test('download client requests carry the user agent when the setting is on', async ({ api, mocks }) => { + await api.general.patch({ httpSendUserAgent: true }); + await mocks.downloadClient.stub(DownloadClientStubs.delugeLoginStub()); + await mocks.downloadClient.resetRequests(); + + const res = await api.downloadClient.test( + buildDownloadClientPayload('deluge', { + name: 'ua-deluge', + host: TEST_CONFIG.mocks.downloadClientUrl, + password: 'admin', + }), + ); + expect(res.ok).toBe(true); + + const entry = await mocks.downloadClient.waitForRequest({ method: 'POST' }); + expect(userAgentOf(entry)).toMatch(USER_AGENT); + }); + + test('the setting takes effect without restarting the app', async ({ api, mocks }) => { + await mocks.arr.stub(ArrStubs.arrHealthStub()); + const instance = { + name: 'ua-toggle', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'good-key', + version: 3, + }; + + await api.general.patch({ httpSendUserAgent: false }); + await mocks.arr.resetRequests(); + await api.arr.testInstance('sonarr', instance); + + const withoutHeader = await mocks.arr.waitForRequest({ method: 'GET' }); + expect(userAgentOf(withoutHeader)).not.toMatch(USER_AGENT); + + await api.general.patch({ httpSendUserAgent: true }); + await mocks.arr.resetRequests(); + await api.arr.testInstance('sonarr', instance); + + const withHeader = await mocks.arr.waitForRequest({ method: 'GET' }); + expect(userAgentOf(withHeader)).toMatch(USER_AGENT); + }); +}); diff --git a/e2e/tests/helpers/mocks/wiremock-client.ts b/e2e/tests/helpers/mocks/wiremock-client.ts index e789892d..475eda92 100644 --- a/e2e/tests/helpers/mocks/wiremock-client.ts +++ b/e2e/tests/helpers/mocks/wiremock-client.ts @@ -33,16 +33,20 @@ export interface Mapping { metadata?: Record; } +export interface LoggedRequest { + method: string; + url: string; + absoluteUrl: string; + headers: Record; + body?: string; + bodyAsBase64?: string; + /** Epoch milliseconds. */ + loggedDate: number; +} + +/** One serve event: what `GET /__admin/requests` returns, unlike `/requests/find`. */ export interface RequestLogEntry { - request: { - method: string; - url: string; - headers: Record; - body?: string; - bodyAsBase64?: string; - /** Epoch milliseconds. */ - loggedDate: number; - }; + request: LoggedRequest; response: { status: number; body?: string; @@ -80,6 +84,14 @@ export class WireMockClient { } } + /** Clears the request journal and leaves the mappings in place. */ + async resetRequests(): Promise { + const res = await fetch(`${this.adminUrl}/requests`, { method: 'DELETE' }); + if (!res.ok) { + throw new Error(`Failed to reset WireMock requests at ${this.adminUrl}: ${res.status}`); + } + } + async resetAll(): Promise { const res = await fetch(`${this.adminUrl}/reset`, { method: 'POST' }); if (!res.ok) { @@ -96,7 +108,7 @@ export class WireMockClient { return data.requests ?? []; } - async findRequests(criteria: { method?: string; urlPath?: string; urlPattern?: string }): Promise { + async findRequests(criteria: { method?: string; urlPath?: string; urlPattern?: string }): Promise { const res = await fetch(`${this.adminUrl}/requests/find`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -112,7 +124,7 @@ export class WireMockClient { async waitForRequest( criteria: { method?: string; urlPath?: string; urlPattern?: string }, timeoutMs = 30_000, - ): Promise { + ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const matches = await this.findRequests(criteria); diff --git a/e2e/tests/malware-blocker/malware-blocker.spec.ts b/e2e/tests/malware-blocker/malware-blocker.spec.ts index 2a51a1a1..25cea61c 100644 --- a/e2e/tests/malware-blocker/malware-blocker.spec.ts +++ b/e2e/tests/malware-blocker/malware-blocker.spec.ts @@ -168,7 +168,7 @@ async function queueRequestSeen(arr: WireMockClient, timeoutMs: number): Promise } async function readDeletes(arr: WireMockClient): Promise { - const reqs = (await arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/.*' })) as unknown as Array<{ url: string }>; + const reqs = await arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/.*' }); return reqs.map((r) => { const parsed = new URL(`http://stub${r.url}`); return { diff --git a/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts b/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts index 09a1603a..d0189d75 100644 --- a/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts +++ b/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts @@ -79,10 +79,10 @@ test.describe.serial('QueueCleaner — fractional sizeleft from Sonarr v3', () = await expect .poll( async () => { - const requests = (await mocks.arr.findRequests({ + const requests = await mocks.arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/.*', - })) as unknown as Array<{ url: string }>; + }); return requests.some((r) => r.url.includes(`/api/v3/queue/${RECORD_ID}`)); }, { timeout: 60_000, intervals: [1_000] },