diff --git a/README.md b/README.md index d35ee6a1..c57d9bfd 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots - **Deluge** - **µTorrent** - **rTorrent** +- **SABnzbd** ### Platforms - **Docker** diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs index 6efe73ca..ae605587 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DeadTorrentConfigControllerTests.cs @@ -96,6 +96,16 @@ public class DeadTorrentConfigControllerTests : IDisposable result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status400BadRequest); } + [Fact] + public async Task Update_EnabledForSabnzbd_ReturnsBadRequest() + { + var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Sabnzbd, "Test Sabnzbd"); + + var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest()); + + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status400BadRequest); + } + [Fact] public async Task Update_NonExistentClient_ReturnsNotFound() { diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs index 80d477de..9eb43cab 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs @@ -1,9 +1,11 @@ +using System.Text.Json; using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; using Cleanuparr.Api.Features.DownloadCleaner.Controllers; using Cleanuparr.Api.Tests.TestHelpers; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -44,6 +46,39 @@ public class DownloadCleanerConfigControllerTests : IDisposable ok.Value.ShouldNotBeNull(); } + [Fact] + public async Task GetDownloadCleanerConfig_IncludesClientsWithoutSeedingCleanupCapability() + { + // Arrange: Sabnzbd has no seeding rules, unlinked handling or dead-torrent detection, + // but it still needs to show up here for its orphaned-files config. + _dataContext.DownloadClients.Add(new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "Test Sabnzbd", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Enabled = true, + }); + await _dataContext.SaveChangesAsync(); + + // Act + var result = await _controller.GetDownloadCleanerConfig(); + + // Assert + JsonElement clients = ResponseContract.Body(result).GetProperty("clients"); + JsonElement sab = clients.EnumerateArray() + .First(c => c.GetProperty("downloadClientName").GetString() == "Test Sabnzbd"); + sab.GetProperty("seedingRules").GetArrayLength().ShouldBe(0); + sab.GetProperty("unlinkedConfig").ValueKind.ShouldBe(JsonValueKind.Null); + sab.GetProperty("deadTorrentConfig").ValueKind.ShouldBe(JsonValueKind.Null); + sab.GetProperty("orphanedFilesConfig").ValueKind.ShouldBe(JsonValueKind.Null); + + // Reading the bundle must not create default config rows for a client that never had one. + (await _dataContext.UnlinkedConfigs.CountAsync()).ShouldBe(0); + (await _dataContext.DeadTorrentConfigs.CountAsync()).ShouldBe(0); + (await _dataContext.OrphanedFilesConfigs.CountAsync()).ShouldBe(0); + } + [Fact] public async Task UpdateDownloadCleanerConfig_Enabled_StartsJob() { diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/SeedingRulesControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/SeedingRulesControllerTests.cs index 21d57c88..56eed923 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/SeedingRulesControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/SeedingRulesControllerTests.cs @@ -207,6 +207,17 @@ public class SeedingRulesControllerTests : IDisposable GetCreatedRule(result).MinSeeders.ShouldBe(5); } + [Fact] + public async Task CreateSeedingRule_ClientWithoutSeedingCleanupCapability_ReturnsBadRequest() + { + var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Sabnzbd, "Test Sabnzbd"); + var request = CreateValidRequest(); + + var result = await _controller.CreateSeedingRule(client.Id, request); + + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status400BadRequest); + } + [Fact] public async Task CreateSeedingRule_SetsMaxInactiveDays() { diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs index c19df105..045dcd22 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs @@ -7,6 +7,7 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -184,13 +185,32 @@ public class UnlinkedConfigControllerTests : IDisposable () => _controller.UpdateUnlinkedConfig(client.Id, dto)); } - private DownloadClientConfig AddDownloadClient() + [Fact] + public async Task UpdateUnlinkedConfig_EnabledForSabnzbd_ReturnsBadRequest() + { + // Arrange: Sabnzbd has no hardlink concept, so unlinked handling cannot apply to it + var client = AddDownloadClient(DownloadClientTypeName.Sabnzbd); + var dto = new UnlinkedConfigRequest + { + Enabled = true, + TargetCategory = "unlinked-cat", + Categories = new List { "movies" }, + }; + + // Act + var result = await _controller.UpdateUnlinkedConfig(client.Id, dto); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(StatusCodes.Status400BadRequest); + } + + private DownloadClientConfig AddDownloadClient(DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent) { var client = new DownloadClientConfig { Id = Guid.NewGuid(), Name = "test-client", - TypeName = DownloadClientTypeName.qBittorrent, + TypeName = typeName, Type = DownloadClientType.Torrent, }; _dataContext.DownloadClients.Add(client); diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs index f0d88f5a..7a3382d5 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs @@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Shared.Helpers; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -85,7 +86,6 @@ public class DownloadClientControllerTests : IDisposable Enabled = true, Name = "my-client", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://localhost:8080", Username = "user", Password = "pass", @@ -101,6 +101,54 @@ public class DownloadClientControllerTests : IDisposable (await _dataContext.DownloadClients.CountAsync()).ShouldBe(1); } + [Fact] + public async Task CreateDownloadClientConfig_SabnzbdWithNoType_StoresUsenet() + { + // Arrange + var request = new CreateDownloadClientRequest + { + Enabled = true, + Name = "sab-client", + TypeName = DownloadClientTypeName.Sabnzbd, + Host = "http://localhost:8080", + ApiKey = "key", + }; + + // Act + await _controller.CreateDownloadClientConfig(request); + + // Assert + var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Name == "sab-client"); + saved.Type.ShouldBe(DownloadClientType.Usenet); + } + + [Fact] + public async Task CreateDownloadClientConfig_SabnzbdWithStaleTorrentTypeInBody_StoresUsenet() + { + // Arrange: CreateDownloadClientRequest no longer declares Type; STJ ignores + // the stale "type" field an older frontend build might still send + var json = """ + { + "enabled": true, + "name": "sab-client-2", + "typeName": "Sabnzbd", + "type": "Torrent", + "host": "http://localhost:8080", + "apiKey": "key" + } + """; + var jsonOptions = new System.Text.Json.JsonSerializerOptions(); + Cleanuparr.Api.Json.CleanuparrJsonConfiguration.ConfigureApiInbound(jsonOptions); + var request = System.Text.Json.JsonSerializer.Deserialize(json, jsonOptions)!; + + // Act + await _controller.CreateDownloadClientConfig(request); + + // Assert + var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Name == "sab-client-2"); + saved.Type.ShouldBe(DownloadClientType.Usenet); + } + [Fact] public async Task CreateDownloadClientConfig_InvalidHost_PropagatesValidationException() { @@ -110,7 +158,6 @@ public class DownloadClientControllerTests : IDisposable Name = "x", Host = string.Empty, TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, }; // Act / Assert — Validate throws, controller's generic catch logs and re-throws @@ -118,6 +165,38 @@ public class DownloadClientControllerTests : IDisposable () => _controller.CreateDownloadClientConfig(request)); } + [Fact] + public async Task CreateDownloadClientConfig_UnknownTypeName_PropagatesValidationException() + { + // Arrange + CreateDownloadClientRequest request = new() + { + Name = "x", + Host = "http://localhost:8080", + TypeName = DownloadClientTypeName.Unknown, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.CreateDownloadClientConfig(request)); + } + + [Fact] + public async Task CreateDownloadClientConfig_UndefinedTypeName_PropagatesValidationException() + { + // Arrange + CreateDownloadClientRequest request = new() + { + Name = "x", + Host = "http://localhost:8080", + TypeName = (DownloadClientTypeName)42, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.CreateDownloadClientConfig(request)); + } + [Fact] public async Task UpdateDownloadClientConfig_UnknownId_ReturnsNotFound() { @@ -127,7 +206,6 @@ public class DownloadClientControllerTests : IDisposable Name = "x", Host = "http://localhost:8080", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, }; // Act @@ -151,7 +229,6 @@ public class DownloadClientControllerTests : IDisposable Name = "renamed", Host = "http://newhost:9090", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, }; // Act @@ -164,6 +241,67 @@ public class DownloadClientControllerTests : IDisposable saved.Host!.ToString().ShouldContain("newhost"); } + [Fact] + public async Task UpdateDownloadClientConfig_PlaceholderApiKeyOnClientWithNoStoredKey_PropagatesValidationException() + { + // Arrange + DownloadClientConfig client = NewClient("orig", DownloadClientTypeName.qBittorrent); + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + UpdateDownloadClientRequest request = new() + { + Name = "renamed", + Host = "http://newhost:9090", + TypeName = DownloadClientTypeName.Sabnzbd, + ApiKey = SensitiveDataHelper.Placeholder, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.UpdateDownloadClientConfig(client.Id, request)); + } + + [Fact] + public async Task UpdateDownloadClientConfig_UnknownTypeName_PropagatesValidationException() + { + // Arrange + DownloadClientConfig client = NewClient("orig", DownloadClientTypeName.qBittorrent); + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + UpdateDownloadClientRequest request = new() + { + Name = "renamed", + Host = "http://newhost:9090", + TypeName = DownloadClientTypeName.Unknown, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.UpdateDownloadClientConfig(client.Id, request)); + } + + [Fact] + public async Task UpdateDownloadClientConfig_UndefinedTypeName_PropagatesValidationException() + { + // Arrange + DownloadClientConfig client = NewClient("orig", DownloadClientTypeName.qBittorrent); + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + UpdateDownloadClientRequest request = new() + { + Name = "renamed", + Host = "http://newhost:9090", + TypeName = (DownloadClientTypeName)42, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.UpdateDownloadClientConfig(client.Id, request)); + } + [Fact] public async Task DeleteDownloadClientConfig_ExistingClient_RemovesAndUnregistersHttpClient() { @@ -207,7 +345,6 @@ public class DownloadClientControllerTests : IDisposable var request = new TestDownloadClientRequest { TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://localhost:8080", Password = "pass", }; @@ -234,7 +371,6 @@ public class DownloadClientControllerTests : IDisposable var request = new TestDownloadClientRequest { TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://localhost:8080", Password = "pass", }; @@ -246,6 +382,84 @@ public class DownloadClientControllerTests : IDisposable result.ShouldBeOfType().StatusCode.ShouldBe(400); } + [Fact] + public async Task TestDownloadClient_NewApiKeyWithPlaceholderPassword_UsesNewApiKeyAndStoredPassword() + { + // Arrange + var client = new DownloadClientConfig + { + Name = "sab", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Host = new Uri("http://localhost:8080"), + Password = "stored-password", + ApiKey = "stored-api-key", + }; + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + DownloadClientConfig? capturedConfig = null; + var downloadService = Substitute.For(); + downloadService.HealthCheckAsync().Returns(new HealthCheckResult { IsHealthy = true }); + _downloadServiceFactory.GetDownloadService(Arg.Do(c => capturedConfig = c)).Returns(downloadService); + + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.Sabnzbd, + Host = "http://localhost:8080", + Password = SensitiveDataHelper.Placeholder, + ApiKey = "new-api-key", + ClientId = client.Id, + }; + + // Act + await _controller.TestDownloadClient(request); + + // Assert + capturedConfig.ShouldNotBeNull(); + capturedConfig.ApiKey.ShouldBe("new-api-key"); + capturedConfig.Password.ShouldBe("stored-password"); + } + + [Fact] + public async Task TestDownloadClient_NewPasswordWithPlaceholderApiKey_UsesNewPasswordAndStoredApiKey() + { + // Arrange + var client = new DownloadClientConfig + { + Name = "sab", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Host = new Uri("http://localhost:8080"), + Password = "stored-password", + ApiKey = "stored-api-key", + }; + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + DownloadClientConfig? capturedConfig = null; + var downloadService = Substitute.For(); + downloadService.HealthCheckAsync().Returns(new HealthCheckResult { IsHealthy = true }); + _downloadServiceFactory.GetDownloadService(Arg.Do(c => capturedConfig = c)).Returns(downloadService); + + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.Sabnzbd, + Host = "http://localhost:8080", + Password = "new-password", + ApiKey = SensitiveDataHelper.Placeholder, + ClientId = client.Id, + }; + + // Act + await _controller.TestDownloadClient(request); + + // Assert + capturedConfig.ShouldNotBeNull(); + capturedConfig.Password.ShouldBe("new-password"); + capturedConfig.ApiKey.ShouldBe("stored-api-key"); + } + [Fact] public async Task TestDownloadClient_InvalidHost_ReturnsBadRequest() { @@ -253,7 +467,6 @@ public class DownloadClientControllerTests : IDisposable var request = new TestDownloadClientRequest { TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = string.Empty, Password = "pass", }; @@ -265,12 +478,48 @@ public class DownloadClientControllerTests : IDisposable result.ShouldBeOfType().StatusCode.ShouldBe(400); } + [Fact] + public async Task TestDownloadClient_UnknownTypeName_ReturnsBadRequest() + { + // Arrange + TestDownloadClientRequest request = new() + { + TypeName = DownloadClientTypeName.Unknown, + Host = "http://localhost:8080", + Password = "pass", + }; + + // Act + IActionResult result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + [Fact] + public async Task TestDownloadClient_UndefinedTypeName_ReturnsBadRequest() + { + // Arrange + TestDownloadClientRequest request = new() + { + TypeName = (DownloadClientTypeName)42, + Host = "http://localhost:8080", + Password = "pass", + }; + + // Act + IActionResult result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + private static DownloadClientConfig NewClient(string name, DownloadClientTypeName typeName) => new() { Id = Guid.NewGuid(), Name = name, TypeName = typeName, - Type = DownloadClientType.Torrent, + Type = typeName.ClientType(), Host = new Uri("http://localhost:8080"), }; } diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs index a5aaca6c..7c507fac 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientResponseContractTests.cs @@ -108,8 +108,49 @@ public class DownloadClientResponseContractTests : IDisposable ResponseContract.Body(result).GetProperty("password").GetString().ShouldBe(SensitiveDataHelper.Placeholder); } + [Fact] + public void GetDownloadClientTypes_ReturnsTheDocumentedWrapperKeys() + { + IActionResult result = _controller.GetDownloadClientTypes(); + + ResponseContract.Keys(result).ShouldBe(["types"]); + } + + [Fact] + public void GetDownloadClientTypes_ReturnsTheDocumentedTypeKeys() + { + IActionResult result = _controller.GetDownloadClientTypes(); + + ResponseContract.Keys(FirstType(result)).ShouldBe(["authFields", "capabilities", "typeName"]); + } + + [Fact] + public void GetDownloadClientTypes_ReturnsOneEntryPerSelectableType() + { + IActionResult result = _controller.GetDownloadClientTypes(); + + ResponseContract.Body(result).GetProperty("types").GetArrayLength() + .ShouldBe(EnumSentinel.SelectableValues().Count); + } + + [Fact] + public void GetDownloadClientTypes_SabnzbdHasNoSeedingCleanupCapability() + { + IActionResult result = _controller.GetDownloadClientTypes(); + + JsonElement sabnzbd = ResponseContract.Body(result).GetProperty("types").EnumerateArray() + .First(t => t.GetProperty("typeName").GetString() == nameof(DownloadClientTypeName.Sabnzbd)); + + sabnzbd.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()) + .ShouldNotContain(nameof(DownloadClientCapability.SeedingCleanup)); + } + + private static JsonElement FirstType(IActionResult result) => + ResponseContract.Body(result).GetProperty("types").EnumerateArray().First(); + private static readonly string[] ClientKeys = [ + "apiKey", "downloadDirectorySource", "downloadDirectoryTarget", "enabled", @@ -150,7 +191,6 @@ public class DownloadClientResponseContractTests : IDisposable Enabled = true, Name = "created", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://localhost:8080", Username = "user", Password = "secret", @@ -161,7 +201,6 @@ public class DownloadClientResponseContractTests : IDisposable Enabled = true, Name = "renamed", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://localhost:8080", Username = "user", Password = "secret", diff --git a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs index 5816618c..fc405337 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/SensitiveData/SensitiveDataInputTests.cs @@ -165,7 +165,6 @@ public class SensitiveDataInputTests { Name = "Updated qBit", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Username = "admin", Password = Placeholder, @@ -194,7 +193,6 @@ public class SensitiveDataInputTests { Name = "qBittorrent", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Username = "admin", Password = "new-password-123", @@ -226,7 +224,6 @@ public class SensitiveDataInputTests { Name = "qBittorrent", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Password = Placeholder, }; @@ -241,7 +238,6 @@ public class SensitiveDataInputTests { Name = "qBittorrent", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Password = "real-password", }; @@ -256,7 +252,6 @@ public class SensitiveDataInputTests { Name = "qBittorrent", TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Password = null, }; @@ -274,7 +269,6 @@ public class SensitiveDataInputTests var request = new TestDownloadClientRequest { TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Password = Placeholder, }; @@ -289,7 +283,6 @@ public class SensitiveDataInputTests var request = new TestDownloadClientRequest { TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Password = Placeholder, ClientId = Guid.NewGuid(), @@ -306,7 +299,6 @@ public class SensitiveDataInputTests var request = new TestDownloadClientRequest { TypeName = DownloadClientTypeName.qBittorrent, - Type = DownloadClientType.Torrent, Host = "http://qbit:8080", Password = "real-password", }; diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs index 0a00a33c..b2863147 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/DeadTorrentConfigController.cs @@ -69,9 +69,9 @@ public class DeadTorrentConfigController : ControllerBase return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found"); } - if (dto.Enabled && client.TypeName is DownloadClientTypeName.rTorrent) + if (dto.Enabled && !client.TypeName.SupportsCapability(DownloadClientCapability.DeadTorrent)) { - return this.ProblemResult(StatusCodes.Status400BadRequest, "Dead torrent handling is not supported for rTorrent (no seeder count available)"); + return this.ProblemResult(StatusCodes.Status400BadRequest, $"Dead torrent handling is not supported for {client.TypeName} (no seeder count available)"); } var existing = await _dataContext.DeadTorrentConfigs diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/SeedingRulesController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/SeedingRulesController.cs index 4e845531..f71980c2 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/SeedingRulesController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/SeedingRulesController.cs @@ -68,6 +68,11 @@ public class SeedingRulesController : ControllerBase return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found"); } + if (!client.TypeName.SupportsCapability(DownloadClientCapability.SeedingCleanup)) + { + return this.ProblemResult(StatusCodes.Status400BadRequest, $"Seeding rules are not supported for {client.TypeName}"); + } + var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client); if (ruleDto.Priority.HasValue && existingRules.Any(r => r.Priority == ruleDto.Priority.Value)) diff --git a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs index 9ea58e25..42d6bb91 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadCleaner/Controllers/UnlinkedConfigController.cs @@ -1,6 +1,7 @@ using Cleanuparr.Api.Extensions; using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses; +using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Microsoft.AspNetCore.Authorization; @@ -68,6 +69,11 @@ public class UnlinkedConfigController : ControllerBase return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found"); } + if (dto.Enabled && !client.TypeName.SupportsCapability(DownloadClientCapability.Unlinked)) + { + return this.ProblemResult(StatusCodes.Status400BadRequest, $"Unlinked download handling is not supported for {client.TypeName}"); + } + var existing = await _dataContext.UnlinkedConfigs .FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId); diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs index eeaf67ba..ba48ef01 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/CreateDownloadClientRequest.cs @@ -15,14 +15,14 @@ public sealed record CreateDownloadClientRequest public DownloadClientTypeName TypeName { get; init; } - public DownloadClientType Type { get; init; } - public string? Host { get; init; } public string? Username { get; init; } public string? Password { get; init; } + public string? ApiKey { get; init; } + public string? UrlBase { get; init; } public string? ExternalUrl { get; init; } @@ -33,6 +33,11 @@ public sealed record CreateDownloadClientRequest public void Validate() { + if (TypeName is DownloadClientTypeName.Unknown || !Enum.IsDefined(TypeName)) + { + throw new ValidationException("Unsupported download client type"); + } + if (string.IsNullOrWhiteSpace(Name)) { throw new ValidationException("Client name cannot be empty"); @@ -57,6 +62,16 @@ public sealed record CreateDownloadClientRequest { throw new ValidationException("Password cannot be a placeholder value"); } + + if (ApiKey.IsPlaceholder()) + { + throw new ValidationException("API key cannot be a placeholder value"); + } + + if (TypeName.RequiresAuthField(DownloadClientAuthField.ApiKey) && string.IsNullOrWhiteSpace(ApiKey)) + { + throw new ValidationException("API key cannot be empty"); + } } public DownloadClientConfig ToEntity() => new() @@ -64,10 +79,11 @@ public sealed record CreateDownloadClientRequest Enabled = Enabled, Name = Name, TypeName = TypeName, - Type = Type, + Type = TypeName.ClientType(), Host = new Uri(Host!, UriKind.RelativeOrAbsolute), Username = Username, Password = Password, + ApiKey = ApiKey, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, DownloadDirectorySource = DownloadDirectorySource, diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs index ae3b9e73..20f25547 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs @@ -11,20 +11,25 @@ public sealed record TestDownloadClientRequest { public DownloadClientTypeName TypeName { get; init; } - public DownloadClientType Type { get; init; } - public string? Host { get; init; } public string? Username { get; init; } public string? Password { get; init; } + public string? ApiKey { get; init; } + public string? UrlBase { get; init; } public Guid? ClientId { get; init; } public void Validate() { + if (TypeName is DownloadClientTypeName.Unknown || !Enum.IsDefined(TypeName)) + { + throw new ValidationException("Unsupported download client type"); + } + if (string.IsNullOrWhiteSpace(Host)) { throw new ValidationException("Host cannot be empty"); @@ -34,27 +39,39 @@ public sealed record TestDownloadClientRequest { throw new ValidationException("Host is not a valid URL"); } + + if (TypeName.RequiresAuthField(DownloadClientAuthField.ApiKey) && string.IsNullOrWhiteSpace(ApiKey) && !ApiKey.IsPlaceholder()) + { + throw new ValidationException("API key cannot be empty"); + } } - public DownloadClientConfig ToTestConfig(string? resolvedPassword = null) + public DownloadClientConfig ToTestConfig(string? resolvedPassword = null, string? resolvedApiKey = null) { var password = resolvedPassword ?? Password; + var apiKey = resolvedApiKey ?? ApiKey; if (password.IsPlaceholder()) { throw new ValidationException("Password cannot be a placeholder value"); } + if (apiKey.IsPlaceholder()) + { + throw new ValidationException("API key cannot be a placeholder value"); + } + return new() { Id = Guid.NewGuid(), Enabled = true, Name = "Test Client", TypeName = TypeName, - Type = Type, + Type = TypeName.ClientType(), Host = new Uri(Host!, UriKind.RelativeOrAbsolute), Username = Username, Password = password, + ApiKey = apiKey, UrlBase = UrlBase, }; } diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs index b82e1ddf..52748817 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/UpdateDownloadClientRequest.cs @@ -15,14 +15,14 @@ public sealed record UpdateDownloadClientRequest public DownloadClientTypeName TypeName { get; init; } - public DownloadClientType Type { get; init; } - public string? Host { get; init; } public string? Username { get; init; } public string? Password { get; init; } + public string? ApiKey { get; init; } + public string? UrlBase { get; init; } public string? ExternalUrl { get; init; } @@ -33,6 +33,11 @@ public sealed record UpdateDownloadClientRequest public void Validate() { + if (TypeName is DownloadClientTypeName.Unknown || !Enum.IsDefined(TypeName)) + { + throw new ValidationException("Unsupported download client type"); + } + if (string.IsNullOrWhiteSpace(Name)) { throw new ValidationException("Client name cannot be empty"); @@ -52,6 +57,11 @@ public sealed record UpdateDownloadClientRequest { throw new ValidationException("External URL is not a valid URL"); } + + if (TypeName.RequiresAuthField(DownloadClientAuthField.ApiKey) && string.IsNullOrWhiteSpace(ApiKey) && !ApiKey.IsPlaceholder()) + { + throw new ValidationException("API key cannot be empty"); + } } public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with @@ -59,10 +69,11 @@ public sealed record UpdateDownloadClientRequest Enabled = Enabled, Name = Name, TypeName = TypeName, - Type = Type, + Type = TypeName.ClientType(), Host = new Uri(Host!, UriKind.RelativeOrAbsolute), Username = Username, Password = Password.IsPlaceholder() ? existing.Password : Password, + ApiKey = ApiKey.IsPlaceholder() ? existing.ApiKey : ApiKey, UrlBase = UrlBase, ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null, DownloadDirectorySource = DownloadDirectorySource, diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs index 999a1274..81ba35b6 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientConfigResponse.cs @@ -26,6 +26,9 @@ public sealed record DownloadClientConfigResponse [SensitiveData] public string? Password { get; init; } + [SensitiveData] + public string? ApiKey { get; init; } + public string? UrlBase { get; init; } public Uri? ExternalUrl { get; init; } @@ -44,6 +47,7 @@ public sealed record DownloadClientConfigResponse Host = config.Host, Username = config.Username, Password = config.Password, + ApiKey = config.ApiKey, UrlBase = config.UrlBase, ExternalUrl = config.ExternalUrl, DownloadDirectorySource = config.DownloadDirectorySource, diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientTypeResponse.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientTypeResponse.cs new file mode 100644 index 00000000..1ab7092c --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Responses/DownloadClientTypeResponse.cs @@ -0,0 +1,27 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Responses; + +/// +/// What a download client type's connection form needs and what its service can do, so the frontend +/// can build the form and feature set from the backend instead of hardcoding per-type lists. +/// +public sealed record DownloadClientTypeResponse +{ + public required DownloadClientTypeName TypeName { get; init; } + + public required List AuthFields { get; init; } + + public required List Capabilities { get; init; } + + public static DownloadClientTypeResponse From(DownloadClientTypeName typeName) => new() + { + TypeName = typeName, + AuthFields = Enum.GetValues() + .Where(field => typeName.SupportsAuthField(field)) + .ToList(), + Capabilities = Enum.GetValues() + .Where(capability => typeName.SupportsCapability(capability)) + .ToList(), + }; +} diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs index 5f52419a..fcfdd747 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Controllers/DownloadClientController.cs @@ -145,6 +145,16 @@ public sealed class DownloadClientController : ControllerBase } } + [HttpGet("download_client/types")] + public IActionResult GetDownloadClientTypes() + { + List types = EnumSentinel.SelectableValues() + .Select(DownloadClientTypeResponse.From) + .ToList(); + + return Ok(new { types }); + } + [HttpPost("download_client/test")] public async Task TestDownloadClient([FromBody] TestDownloadClientRequest request) { @@ -153,8 +163,9 @@ public sealed class DownloadClientController : ControllerBase request.Validate(); string? resolvedPassword = null; + string? resolvedApiKey = null; - if (request.Password.IsPlaceholder() && request.ClientId.HasValue) + if ((request.Password.IsPlaceholder() || request.ApiKey.IsPlaceholder()) && request.ClientId.HasValue) { var existingClient = await _dataContext.DownloadClients .AsNoTracking() @@ -165,10 +176,11 @@ public sealed class DownloadClientController : ControllerBase return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {request.ClientId.Value} not found"); } - resolvedPassword = existingClient.Password; + resolvedPassword = request.Password.IsPlaceholder() ? existingClient.Password : null; + resolvedApiKey = request.ApiKey.IsPlaceholder() ? existingClient.ApiKey : null; } - var testConfig = request.ToTestConfig(resolvedPassword); + var testConfig = request.ToTestConfig(resolvedPassword, resolvedApiKey); using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig); var healthResult = await downloadService.HealthCheckAsync(); diff --git a/code/backend/Cleanuparr.Domain/Entities/IDownloadItem.cs b/code/backend/Cleanuparr.Domain/Entities/IDownloadItem.cs new file mode 100644 index 00000000..63f42a49 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/IDownloadItem.cs @@ -0,0 +1,29 @@ +namespace Cleanuparr.Domain.Entities; + +/// +/// Universal abstraction for a download item across all download clients, torrent or usenet. +/// +public interface IDownloadItem +{ + string DownloadId { get; } + + string Name { get; } + + long Size { get; } + + double CompletionPercentage { get; } + + long DownloadedBytes { get; } + + string? Category { get; set; } + + string SavePath { get; } + + /// + /// Determines if this item should be ignored based on the provided patterns. + /// Checks if any pattern matches the download id, category, or a client-specific field such as a torrent tag or tracker. + /// + /// List of patterns to check against + /// True if the item matches any ignore pattern + bool IsIgnored(IReadOnlyList ignoredDownloads); +} diff --git a/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs b/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs index a8a182b0..c8609283 100644 --- a/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Domain/Entities/ITorrentItemWrapper.cs @@ -6,22 +6,19 @@ namespace Cleanuparr.Domain.Entities; /// Universal abstraction for a torrent item across all download clients. /// Provides a unified interface for accessing torrent properties and state. /// -public interface ITorrentItemWrapper +public interface ITorrentItemWrapper : IDownloadItem { - string Hash { get; } - - string Name { get; } - bool IsPrivate { get; } - long Size { get; } - - double CompletionPercentage { get; } - - long DownloadedBytes { get; } - long DownloadSpeed { get; } - + + /// + /// Whether the download client has this item stopped or paused. + /// + bool IsStopped { get; } + + bool IsDownloading(); + double Ratio { get; } /// @@ -47,10 +44,6 @@ public interface ITorrentItemWrapper DateTime? LastActivityTime { get; } - string? Category { get; set; } - - string SavePath { get; } - /// /// Tracker domains extracted from all trackers associated with this torrent. /// Used for tracker-based seeding rule matching. @@ -63,20 +56,5 @@ public interface ITorrentItemWrapper /// IReadOnlyList Tags { get; } - /// - /// Whether the download client has this torrent stopped or paused. - /// - bool IsStopped { get; } - - bool IsDownloading(); - bool IsStalled(); - - /// - /// Determines if this torrent should be ignored based on the provided patterns. - /// Checks if any pattern matches the torrent name, hash, or tracker. - /// - /// List of patterns to check against - /// True if the torrent matches any ignore pattern - bool IsIgnored(IReadOnlyList ignoredDownloads); } diff --git a/code/backend/Cleanuparr.Domain/Entities/LazyLibrarian/LazyLibrarianQueueItem.cs b/code/backend/Cleanuparr.Domain/Entities/LazyLibrarian/LazyLibrarianQueueItem.cs index 921e4de0..9f994fa3 100644 --- a/code/backend/Cleanuparr.Domain/Entities/LazyLibrarian/LazyLibrarianQueueItem.cs +++ b/code/backend/Cleanuparr.Domain/Entities/LazyLibrarian/LazyLibrarianQueueItem.cs @@ -24,5 +24,9 @@ public sealed record LazyLibrarianQueueItem /// public required LazyLibrarianOrigin Origin { get; init; } - public bool WasAdoptedByLazyLibrarian => Origin is not LazyLibrarianOrigin.New; + /// + /// LazyLibrarian records an origin only for torrent snatches; its usenet path never sets one. + /// + public bool WasAdoptedByLazyLibrarian => + Source.ClientType() is DownloadClientType.Torrent && Origin is not LazyLibrarianOrigin.New; } diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdActionResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdActionResponse.cs new file mode 100644 index 00000000..1ad3da52 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdActionResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// The error envelope SABnzbd can return on any call. Read responses omit status on success, +/// so signals an error when false and nothing otherwise. +/// +public sealed record SabnzbdActionResponse +{ + [JsonPropertyName("status")] + public bool? Status { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdConfigData.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdConfigData.cs new file mode 100644 index 00000000..60eb7636 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdConfigData.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// The config payload of SABnzbd's get_config API response. +/// +public sealed record SabnzbdConfigData +{ + [JsonPropertyName("misc")] + public SabnzbdMiscConfig? Misc { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdGetConfigResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdGetConfigResponse.cs new file mode 100644 index 00000000..36fb2641 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdGetConfigResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// The response for SABnzbd's mode=get_config API call. +/// +public sealed record SabnzbdGetConfigResponse +{ + [JsonPropertyName("config")] + public SabnzbdConfigData? Config { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistoryData.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistoryData.cs new file mode 100644 index 00000000..d776a7e7 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistoryData.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +public sealed record SabnzbdHistoryData +{ + [JsonPropertyName("slots")] + public IReadOnlyList Slots { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistoryResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistoryResponse.cs new file mode 100644 index 00000000..4ff81bba --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistoryResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +public sealed record SabnzbdHistoryResponse +{ + [JsonPropertyName("history")] + public SabnzbdHistoryData? History { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistorySlot.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistorySlot.cs new file mode 100644 index 00000000..e262ac39 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdHistorySlot.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// A single job that has left the SABnzbd queue, successfully or not. +/// +public sealed record SabnzbdHistorySlot +{ + [JsonPropertyName("nzo_id")] + public string? NzoId { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// + /// "Completed" or "Failed" once post-processing (verify/repair/extract/move) finishes; until then one of + /// Queued/QuickCheck/Verifying/Repairing/Fetching/Extracting/Moving/Running. + /// + [JsonPropertyName("status")] + public string? Status { get; init; } + + /// + /// The final output directory on disk. + /// + [JsonPropertyName("storage")] + public string? Storage { get; init; } + + [JsonPropertyName("fail_message")] + public string? FailMessage { get; init; } + + [JsonPropertyName("bytes")] + public long Bytes { get; init; } + + [JsonPropertyName("category")] + public string? Category { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdMiscConfig.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdMiscConfig.cs new file mode 100644 index 00000000..8759ccd9 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdMiscConfig.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// The subset of SABnzbd's misc config section Cleanuparr reads. +/// +public sealed record SabnzbdMiscConfig +{ + /// + /// The folder SABnzbd downloads in-progress jobs into, before they move to complete_dir. + /// + [JsonPropertyName("download_dir")] + public string? DownloadDir { get; init; } + + /// + /// The folder SABnzbd moves a job into once it finishes downloading and post-processing. + /// + [JsonPropertyName("complete_dir")] + public string? CompleteDir { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueData.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueData.cs new file mode 100644 index 00000000..cbdf2224 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueData.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// The SABnzbd queue. Download speed is reported for the server as a whole, not per job. +/// +public sealed record SabnzbdQueueData +{ + [JsonPropertyName("kbpersec")] + public double KbPerSec { get; init; } + + [JsonPropertyName("paused")] + public bool Paused { get; init; } + + [JsonPropertyName("slots")] + public IReadOnlyList Slots { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueResponse.cs new file mode 100644 index 00000000..fc35e3cf --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +public sealed record SabnzbdQueueResponse +{ + [JsonPropertyName("queue")] + public SabnzbdQueueData? Queue { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueSlot.cs b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueSlot.cs new file mode 100644 index 00000000..c83e1c1a --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Sabnzbd/SabnzbdQueueSlot.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Entities.Sabnzbd; + +/// +/// A single job in the SABnzbd queue (not yet finished). +/// +public sealed record SabnzbdQueueSlot +{ + [JsonPropertyName("nzo_id")] + public string? NzoId { get; init; } + + [JsonPropertyName("filename")] + public string? Filename { get; init; } + + /// + /// For example "Downloading", "Paused", "Queued", "Checking", "Extracting", "Verifying", "Repairing", "Moving", "Fetching", "QuickCheck". + /// + [JsonPropertyName("status")] + public string? Status { get; init; } + + [JsonPropertyName("mb")] + public double Mb { get; init; } + + [JsonPropertyName("mbleft")] + public double MbLeft { get; init; } + + [JsonPropertyName("cat")] + public string? Category { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs b/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs index 25e5edd4..235fd444 100644 --- a/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs +++ b/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs @@ -12,4 +12,5 @@ public enum DeleteReason AllFilesSkippedByQBit, AllFilesBlocked, AtLeastOneFileBlocked, + DownloadFailed, } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs new file mode 100644 index 00000000..64d95d71 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientAuthField.cs @@ -0,0 +1,11 @@ +namespace Cleanuparr.Domain.Enums; + +/// +/// A credential field a download client's connection form can ask for. +/// +public enum DownloadClientAuthField +{ + Username, + Password, + ApiKey, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientCapability.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientCapability.cs new file mode 100644 index 00000000..8e1cb7f8 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientCapability.cs @@ -0,0 +1,48 @@ +namespace Cleanuparr.Domain.Enums; + +/// +/// A capability a download client service may implement, mirrored by one of the +/// I*Capable interfaces under Infrastructure/Features/DownloadClient. +/// +public enum DownloadClientCapability +{ + /// + /// Can report whether an *arr queue item should be removed. Backs IQueueCheckCapable. + /// + QueueCheck, + + /// + /// Can block unwanted files from completing. Backs IFileBlockingCapable. + /// + FileBlocking, + + /// + /// Can report the on-disk paths its downloads claim. Backs IOrphanClaimsCapable. + /// + OrphanClaims, + + /// + /// Downloads seed, so seeding rules apply. Backs ISeedingCleanupCapable. + /// + SeedingCleanup, + + /// + /// Files can be checked for hardlinks, so unlinked downloads can be moved. Backs IUnlinkedCapable. + /// + Unlinked, + + /// + /// Seeding rules can filter by tag/label. Backs ITagFilterable on the client's seeding rule type. + /// + TagFiltering, + + /// + /// Seeding rules can filter by minimum seeders. Backs ISeedersFilterable on the client's seeding rule type. + /// + SeedersFiltering, + + /// + /// Reports a seeder count, so dead-torrent detection applies. Backs IDeadTorrentCapable. + /// + DeadTorrent, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeName.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeName.cs index 523c356d..c58afd6b 100644 --- a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeName.cs +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeName.cs @@ -7,6 +7,7 @@ public enum DownloadClientTypeName Transmission, uTorrent, rTorrent, + Sabnzbd, /// /// Text this build does not know. diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs new file mode 100644 index 00000000..b5301ee4 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs @@ -0,0 +1,90 @@ +namespace Cleanuparr.Domain.Enums; + +public static class DownloadClientTypeNameExtensions +{ + private static readonly Dictionary AuthFields = new() + { + [DownloadClientTypeName.qBittorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + [DownloadClientTypeName.Deluge] = [DownloadClientAuthField.Password], + [DownloadClientTypeName.Transmission] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + [DownloadClientTypeName.uTorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + [DownloadClientTypeName.rTorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + [DownloadClientTypeName.Sabnzbd] = [DownloadClientAuthField.ApiKey], + }; + + /// + /// Whether the client type accepts this credential field. + /// + public static bool SupportsAuthField(this DownloadClientTypeName typeName, DownloadClientAuthField field) => + AuthFields.TryGetValue(typeName, out var fields) && fields.Contains(field); + + /// + /// Maps a client type name to its download protocol, so callers never pass this independently. + /// + public static DownloadClientType ClientType(this DownloadClientTypeName typeName) => typeName switch + { + DownloadClientTypeName.qBittorrent => DownloadClientType.Torrent, + DownloadClientTypeName.Deluge => DownloadClientType.Torrent, + DownloadClientTypeName.Transmission => DownloadClientType.Torrent, + DownloadClientTypeName.uTorrent => DownloadClientType.Torrent, + DownloadClientTypeName.rTorrent => DownloadClientType.Torrent, + DownloadClientTypeName.Sabnzbd => DownloadClientType.Usenet, + _ => throw new ArgumentOutOfRangeException(nameof(typeName), typeName, "Unknown download client type name"), + }; + + /// + /// True when the field is the client's only credential, so leaving it blank means it cannot authenticate at all. + /// + public static bool RequiresAuthField(this DownloadClientTypeName typeName, DownloadClientAuthField field) => + AuthFields.TryGetValue(typeName, out var fields) && fields is [var only] && only == field; + + private static readonly DownloadClientCapability[] BaseTorrentCapabilities = + [ + DownloadClientCapability.QueueCheck, + DownloadClientCapability.FileBlocking, + DownloadClientCapability.OrphanClaims, + DownloadClientCapability.SeedingCleanup, + DownloadClientCapability.Unlinked, + ]; + + private static readonly DownloadClientCapability[] UsenetCapabilities = + [ + DownloadClientCapability.QueueCheck, + DownloadClientCapability.FileBlocking, + DownloadClientCapability.OrphanClaims, + ]; + + /// + /// Every torrent client reports a seeder count and supports min-seeders filtering, except rTorrent. + /// + private static readonly DownloadClientCapability[] SeedersCapableTorrentCapabilities = + [ + ..BaseTorrentCapabilities, + DownloadClientCapability.SeedersFiltering, + DownloadClientCapability.DeadTorrent, + ]; + + private static readonly Dictionary Capabilities = new() + { + [DownloadClientTypeName.qBittorrent] = + [ + ..SeedersCapableTorrentCapabilities, + DownloadClientCapability.TagFiltering, + ], + [DownloadClientTypeName.Deluge] = SeedersCapableTorrentCapabilities, + [DownloadClientTypeName.Transmission] = + [ + ..SeedersCapableTorrentCapabilities, + DownloadClientCapability.TagFiltering, + ], + [DownloadClientTypeName.uTorrent] = SeedersCapableTorrentCapabilities, + [DownloadClientTypeName.rTorrent] = BaseTorrentCapabilities, + [DownloadClientTypeName.Sabnzbd] = UsenetCapabilities, + }; + + /// + /// Whether the client type's service implements the capability's I*Capable interface. + /// + public static bool SupportsCapability(this DownloadClientTypeName typeName, DownloadClientCapability capability) => + Capabilities.TryGetValue(typeName, out var capabilities) && capabilities.Contains(capability); +} diff --git a/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianDownloadMode.cs b/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianDownloadMode.cs index f790c31e..96802147 100644 --- a/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianDownloadMode.cs +++ b/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianDownloadMode.cs @@ -4,7 +4,7 @@ namespace Cleanuparr.Domain.Enums; /// /// The provider type LazyLibrarian recorded for a snatch, from its NZBmode column. -/// Torrent, Torznab and Magnet go to a torrent client. +/// Torrent, Torznab and Magnet go to a torrent client; Nzb goes to a usenet client when the source is SABnzbd. /// [JsonConverter(typeof(TolerantEnumConverter))] public enum LazyLibrarianDownloadMode diff --git a/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianSourceExtensions.cs b/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianSourceExtensions.cs index 547c0a96..cdc83b76 100644 --- a/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianSourceExtensions.cs +++ b/code/backend/Cleanuparr.Domain/Enums/LazyLibrarianSourceExtensions.cs @@ -2,21 +2,24 @@ namespace Cleanuparr.Domain.Enums; public static class LazyLibrarianSourceExtensions { - private static readonly HashSet TorrentClients = - [ - LazyLibrarianSource.QBittorrent, - LazyLibrarianSource.Transmission, - LazyLibrarianSource.DelugeWebUi, - LazyLibrarianSource.DelugeRpc, - LazyLibrarianSource.UTorrent, - LazyLibrarianSource.RTorrent, - ]; + private static readonly Dictionary QueryableClients = new() + { + [LazyLibrarianSource.QBittorrent] = DownloadClientType.Torrent, + [LazyLibrarianSource.Transmission] = DownloadClientType.Torrent, + [LazyLibrarianSource.DelugeWebUi] = DownloadClientType.Torrent, + [LazyLibrarianSource.DelugeRpc] = DownloadClientType.Torrent, + [LazyLibrarianSource.UTorrent] = DownloadClientType.Torrent, + [LazyLibrarianSource.RTorrent] = DownloadClientType.Torrent, + [LazyLibrarianSource.Sabnzbd] = DownloadClientType.Usenet, + }; /// - /// A blackhole or Synology row keeps a torrent NZBmode but never reaches a client we can query. - /// Its DownloadID is a path or a task id, so it collides across unrelated rows. + /// The Cleanuparr client type that can query this source, or null when it never reaches a client we can query + /// (blackhole, Synology, direct, irc, or an unsupported usenet downloader like NZBGet). + /// Its DownloadID is then a path or a task id, so it collides across unrelated rows. /// - public static bool IsTorrentClient(this LazyLibrarianSource source) => TorrentClients.Contains(source); + public static DownloadClientType? ClientType(this LazyLibrarianSource source) => + QueryableClients.TryGetValue(source, out DownloadClientType type) ? type : null; public static string ToWireValue(this LazyLibrarianSource source) => LazyLibrarianSourceConverter.ToWireValue(source); diff --git a/code/backend/Cleanuparr.Domain/Exceptions/SabnzbdClientException.cs b/code/backend/Cleanuparr.Domain/Exceptions/SabnzbdClientException.cs new file mode 100644 index 00000000..c17ba44f --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Exceptions/SabnzbdClientException.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Domain.Exceptions; + +public class SabnzbdClientException : Exception +{ + public SabnzbdClientException(string message) : base(message) + { + } + + public SabnzbdClientException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs index ccb323b9..beadc104 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeItemWrapperTests.cs @@ -29,7 +29,7 @@ public class DelugeItemWrapperTests var wrapper = new DelugeItemWrapper(downloadStatus); // Act - var result = wrapper.Hash; + var result = wrapper.DownloadId; // Assert result.ShouldBe(expectedHash); @@ -48,7 +48,7 @@ public class DelugeItemWrapperTests var wrapper = new DelugeItemWrapper(downloadStatus); // Act - var result = wrapper.Hash; + var result = wrapper.DownloadId; // Assert result.ShouldBe(string.Empty); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs index e933f1c7..aaf8dc34 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs @@ -48,7 +48,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.Count.ShouldBe(2); - foreach (var item in result) { item.Hash.ShouldNotBeNull(); } + foreach (var item in result) { item.DownloadId.ShouldNotBeNull(); } } [Fact] @@ -89,7 +89,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -112,7 +112,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -135,7 +135,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -202,7 +202,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } } @@ -314,7 +314,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -354,7 +354,7 @@ public class DelugeServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -452,7 +452,7 @@ public class DelugeServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteTorrents(Arg.Is>(h => h.Contains("test-hash")), true) @@ -473,7 +473,7 @@ public class DelugeServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "UPPERCASE-HASH"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteTorrents(Arg.Any>(), true) @@ -494,7 +494,7 @@ public class DelugeServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteTorrents(Arg.Is>(h => h.Contains("test-hash")), false) @@ -522,7 +522,7 @@ public class DelugeServiceDCTests : IClassFixture DelugeService sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .PauseTorrents(Arg.Is>(h => h.Contains("test-hash"))) @@ -543,7 +543,7 @@ public class DelugeServiceDCTests : IClassFixture DelugeService sut = _fixture.CreateSut(); const string hash = "UPPERCASE-HASH"; ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .PauseTorrents(Arg.Any>()) @@ -563,7 +563,7 @@ public class DelugeServiceDCTests : IClassFixture // Arrange DelugeService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("TEST-HASH"); + mockTorrent.DownloadId.Returns("TEST-HASH"); _fixture.ClientWrapper .PauseTorrents(Arg.Any>()) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadClientCapabilityConsistencyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadClientCapabilityConsistencyTests.cs new file mode 100644 index 00000000..fb192f74 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadClientCapabilityConsistencyTests.cs @@ -0,0 +1,206 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; +using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; +using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent; +using Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; +using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; +using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; +using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Features.Notifications; +using Cleanuparr.Infrastructure.Http; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Realtime; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Cleanuparr.Persistence.Providers; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +/// +/// Guards the capability map in against drifting from what +/// each client's service actually implements: a new client type, or a service that gains or drops an +/// I*Capable interface without updating the map, fails here instead of silently falling out of +/// whichever job narrows via OfType<T>(). +/// +public class DownloadClientCapabilityConsistencyTests : IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly DownloadServiceFactory _factory; + private readonly MemoryCache _memoryCache; + + public DownloadClientCapabilityConsistencyTests() + { + var services = new ServiceCollection(); + + _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + services.AddSingleton(_memoryCache); + + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + + var httpClientProvider = Substitute.For(); + httpClientProvider.CreateClient(Arg.Any()).Returns(new HttpClient()); + services.AddSingleton(httpClientProvider); + + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + + services.AddLogging(); + + var eventsContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var eventsContext = new EventsContext(eventsContextOptions); + IEventNotifier eventNotifier = Substitute.For(); + + services.AddSingleton(new EventPublisher( + eventsContext, + eventNotifier, + Substitute.For>(), + Substitute.For(), + Substitute.For(), + new SqliteDatabaseProvider(), + TimeProvider.System)); + + var scopeFactory = Substitute.For(); + + services.AddSingleton(new BlocklistProvider( + Substitute.For>(), + scopeFactory, + _memoryCache, + TimeProvider.System)); + + services.AddSingleton(TimeProvider.System); + + _serviceProvider = services.BuildServiceProvider(); + _factory = new DownloadServiceFactory(Substitute.For>(), _serviceProvider); + } + + public void Dispose() + { + _memoryCache.Dispose(); + } + + public static IEnumerable ClientTypeNames() => + EnumSentinel.SelectableValues().Select(typeName => new object[] { typeName }); + + [Theory] + [MemberData(nameof(ClientTypeNames))] + public void SupportsCapability_MatchesServiceInterfaces(DownloadClientTypeName typeName) + { + DownloadClientConfig config = new() + { + Id = Guid.NewGuid(), + Name = $"Test {typeName} client", + TypeName = typeName, + Type = typeName == DownloadClientTypeName.Sabnzbd ? DownloadClientType.Usenet : DownloadClientType.Torrent, + Host = new Uri("http://test.example.com"), + Enabled = true, + }; + + IDownloadService service = _factory.GetDownloadService(config); + + (service is IQueueCheckCapable).ShouldBe( + typeName.SupportsCapability(DownloadClientCapability.QueueCheck), + $"IQueueCheckCapable mismatch for {typeName}"); + + (service is IFileBlockingCapable).ShouldBe( + typeName.SupportsCapability(DownloadClientCapability.FileBlocking), + $"IFileBlockingCapable mismatch for {typeName}"); + + (service is IOrphanClaimsCapable).ShouldBe( + typeName.SupportsCapability(DownloadClientCapability.OrphanClaims), + $"IOrphanClaimsCapable mismatch for {typeName}"); + + (service is ISeedingCleanupCapable).ShouldBe( + typeName.SupportsCapability(DownloadClientCapability.SeedingCleanup), + $"ISeedingCleanupCapable mismatch for {typeName}"); + + (service is IUnlinkedCapable).ShouldBe( + typeName.SupportsCapability(DownloadClientCapability.Unlinked), + $"IUnlinkedCapable mismatch for {typeName}"); + + (service is IDeadTorrentCapable).ShouldBe( + typeName.SupportsCapability(DownloadClientCapability.DeadTorrent), + $"IDeadTorrentCapable mismatch for {typeName}"); + } + + /// + /// Maps a torrent client type to the ISeedingRule type it persists, so the capability map can be + /// checked against what that rule type actually implements. Sabnzbd has no seeding rule type and + /// is covered separately. + /// + private static readonly Dictionary SeedingRuleTypes = new() + { + [DownloadClientTypeName.qBittorrent] = typeof(QBitSeedingRule), + [DownloadClientTypeName.Deluge] = typeof(DelugeSeedingRule), + [DownloadClientTypeName.Transmission] = typeof(TransmissionSeedingRule), + [DownloadClientTypeName.uTorrent] = typeof(UTorrentSeedingRule), + [DownloadClientTypeName.rTorrent] = typeof(RTorrentSeedingRule), + }; + + public static IEnumerable TorrentClientTypeNames() => + SeedingRuleTypes.Keys.Select(typeName => new object[] { typeName }); + + [Theory] + [MemberData(nameof(TorrentClientTypeNames))] + public void SupportsCapability_TagFiltering_MatchesSeedingRuleType(DownloadClientTypeName typeName) + { + bool ruleImplementsTagFilterable = SeedingRuleTypes[typeName].IsAssignableTo(typeof(ITagFilterable)); + + typeName.SupportsCapability(DownloadClientCapability.TagFiltering).ShouldBe( + ruleImplementsTagFilterable, + $"TagFiltering mismatch for {typeName}"); + } + + [Theory] + [MemberData(nameof(TorrentClientTypeNames))] + public void SupportsCapability_SeedersFiltering_MatchesSeedingRuleType(DownloadClientTypeName typeName) + { + bool ruleImplementsSeedersFilterable = SeedingRuleTypes[typeName].IsAssignableTo(typeof(ISeedersFilterable)); + + typeName.SupportsCapability(DownloadClientCapability.SeedersFiltering).ShouldBe( + ruleImplementsSeedersFilterable, + $"SeedersFiltering mismatch for {typeName}"); + } + + [Fact] + public void EveryClientType_HasAMapEntry() + { + // SupportsCapability returns false, never throws, for a type name missing from the map, so a type with + // no entry at all reads as "supports nothing" rather than failing loudly. Every real client supports + // at least one capability, so a type matching none of them has fallen out of the map. + foreach (DownloadClientTypeName typeName in EnumSentinel.SelectableValues()) + { + bool supportsAny = Enum.GetValues().Any(capability => typeName.SupportsCapability(capability)); + + supportsAny.ShouldBeTrue($"{typeName} is missing from the DownloadClientTypeNameExtensions capability map"); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs index a2627be1..368a58ea 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs @@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent; +using Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; using Cleanuparr.Infrastructure.Features.Files; @@ -51,6 +52,7 @@ public class DownloadServiceFactoryTests : IDisposable services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); @@ -177,6 +179,20 @@ public class DownloadServiceFactoryTests : IDisposable service.ShouldBeOfType(); } + [Fact] + public void GetDownloadService_Sabnzbd_ReturnsSabnzbdService() + { + // Arrange + var config = CreateClientConfig(DownloadClientTypeName.Sabnzbd); + + // Act + var service = _factory.GetDownloadService(config); + + // Assert + service.ShouldNotBeNull(); + service.ShouldBeOfType(); + } + [Fact] public void GetDownloadService_UnsupportedType_ThrowsNotSupportedException() { @@ -248,6 +264,7 @@ public class DownloadServiceFactoryTests : IDisposable [InlineData(DownloadClientTypeName.Transmission, typeof(TransmissionService))] [InlineData(DownloadClientTypeName.uTorrent, typeof(UTorrentService))] [InlineData(DownloadClientTypeName.rTorrent, typeof(RTorrentService))] + [InlineData(DownloadClientTypeName.Sabnzbd, typeof(SabnzbdService))] public void GetDownloadService_AllSupportedTypes_ReturnCorrectServiceType( DownloadClientTypeName typeName, Type expectedServiceType) { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs index 55e09b28..29624f77 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitItemWrapperTests.cs @@ -38,7 +38,7 @@ public class QBitItemWrapperTests var wrapper = new QBitItemWrapper(torrentInfo, trackers, false); // Act - var result = wrapper.Hash; + var result = wrapper.DownloadId; // Assert result.ShouldBe(expectedHash); @@ -53,7 +53,7 @@ public class QBitItemWrapperTests var wrapper = new QBitItemWrapper(torrentInfo, trackers, false); // Act - var result = wrapper.Hash; + var result = wrapper.DownloadId; // Assert result.ShouldBe(string.Empty); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs index ea1c6dc4..37bb0d6c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs @@ -64,7 +64,7 @@ public class QBitServiceDCTests : IClassFixture // Assert result.Count.ShouldBe(2); - foreach (var item in result) { item.Hash.ShouldNotBeNull(); } + foreach (var item in result) { item.DownloadId.ShouldNotBeNull(); } } [Fact] @@ -193,7 +193,7 @@ public class QBitServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -229,7 +229,7 @@ public class QBitServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash2"); + result[0].DownloadId.ShouldBe("hash2"); } [Fact] @@ -422,7 +422,7 @@ public class QBitServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -481,7 +481,7 @@ public class QBitServiceDCTests : IClassFixture private static ITorrentItemWrapper CreateTorrentWithSeederCount(string hash, int? seederCount) { var torrent = Substitute.For(); - torrent.Hash.Returns(hash); + torrent.DownloadId.Returns(hash); torrent.Name.Returns($"Test {hash}"); torrent.Category.Returns("movies"); torrent.IsPrivate.Returns(false); @@ -882,7 +882,7 @@ public class QBitServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash2"); + result[0].DownloadId.ShouldBe("hash2"); } [Fact] @@ -964,7 +964,7 @@ public class QBitServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -1068,7 +1068,7 @@ public class QBitServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "test-hash"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteAsync(Arg.Is>(h => h.Contains(hash)), true) @@ -1089,7 +1089,7 @@ public class QBitServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "test-hash"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .DeleteAsync(Arg.Any>(), Arg.Any()) @@ -1117,7 +1117,7 @@ public class QBitServiceDCTests : IClassFixture QBitService sut = _fixture.CreateSut(); const string hash = "test-hash"; ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .PauseAsync(Arg.Is>(h => h.Contains(hash))) @@ -1137,7 +1137,7 @@ public class QBitServiceDCTests : IClassFixture // Arrange QBitService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); _fixture.ClientWrapper .PauseAsync(Arg.Any>()) @@ -1197,7 +1197,7 @@ public class QBitServiceDCTests : IClassFixture { var sut = _fixture.CreateSut(); var torrent = Substitute.For(); - torrent.Hash.Returns("hash1"); + torrent.DownloadId.Returns("hash1"); torrent.Name.Returns("Test"); torrent.Category.Returns("movies"); @@ -1216,7 +1216,7 @@ public class QBitServiceDCTests : IClassFixture { var sut = _fixture.CreateSut(); var torrent = Substitute.For(); - torrent.Hash.Returns("hash1"); + torrent.DownloadId.Returns("hash1"); torrent.Name.Returns("Test"); torrent.Category.Returns("movies"); @@ -1235,7 +1235,7 @@ public class QBitServiceDCTests : IClassFixture { var sut = _fixture.CreateSut(); var torrent = Substitute.For(); - torrent.Hash.Returns("hash1"); + torrent.DownloadId.Returns("hash1"); torrent.Name.Returns("Test"); torrent.Category.Returns("movies"); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs index 828158c0..badcdcda 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; +using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; @@ -343,7 +344,7 @@ public class QBitServiceTests : IClassFixture // Assert result.Found.ShouldBeTrue(); - result.Torrent!.TrackerDomains.ShouldBeEmpty(); + ((ITorrentItemWrapper)result.Item!).TrackerDomains.ShouldBeEmpty(); } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs index 6bfedde8..44a3f3ba 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentItemWrapperTests.cs @@ -21,7 +21,7 @@ public class RTorrentItemWrapperTests var wrapper = new RTorrentItemWrapper(torrent, null, TimeProvider.System); // Assert - wrapper.Hash.ShouldBe("ABC123DEF456"); + wrapper.DownloadId.ShouldBe("ABC123DEF456"); } [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs index affc185d..7a539dcf 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs @@ -57,7 +57,7 @@ public class RTorrentServiceDCTests : IClassFixture // Assert - only torrents with State=1 AND Complete=1 should be returned result.Count.ShouldBe(2); - foreach (var item in result) { item.Hash.ShouldNotBeNull(); } + foreach (var item in result) { item.DownloadId.ShouldNotBeNull(); } } [Fact] @@ -102,7 +102,7 @@ public class RTorrentServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("HASH1"); + result[0].DownloadId.ShouldBe("HASH1"); } [Fact] @@ -295,7 +295,7 @@ public class RTorrentServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("HASH1"); + result[0].DownloadId.ShouldBe("HASH1"); } [Fact] @@ -334,7 +334,7 @@ public class RTorrentServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); var hash = "lowercase"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); mockTorrent.SavePath.Returns("/test/path"); _fixture.ClientWrapper @@ -375,7 +375,7 @@ public class RTorrentServiceDCTests : IClassFixture var sut = _fixture.CreateSut(config); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("hash"); + mockTorrent.DownloadId.Returns("hash"); mockTorrent.SavePath.Returns("/downloads/seed-rule-tracker"); try @@ -408,7 +408,7 @@ public class RTorrentServiceDCTests : IClassFixture // Arrange RTorrentService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("lowercase"); + mockTorrent.DownloadId.Returns("lowercase"); mockTorrent.SavePath.Returns("/test/path"); _fixture.ClientWrapper @@ -429,7 +429,7 @@ public class RTorrentServiceDCTests : IClassFixture // Arrange RTorrentService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("LOWERCASE"); + mockTorrent.DownloadId.Returns("LOWERCASE"); mockTorrent.SavePath.Returns("/test/path"); _fixture.ClientWrapper @@ -454,7 +454,7 @@ public class RTorrentServiceDCTests : IClassFixture await File.WriteAllTextAsync(Path.Combine(savePath, "file.mkv"), "content"); ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("HASH1"); + mockTorrent.DownloadId.Returns("HASH1"); mockTorrent.SavePath.Returns(savePath); _fixture.ClientWrapper diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs index 4ea9db6a..60257198 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs @@ -774,6 +774,91 @@ public class RTorrentServiceTests : IClassFixture } } + public class DeleteDownload_Scenarios : RTorrentServiceTests + { + public DeleteDownload_Scenarios(RTorrentServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task DeleteFilesFalse_OnlyRemovesTorrent() + { + var sut = _fixture.CreateSut(); + var torrent = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "TEST-HASH", Name = "Test" }, null, TimeProvider.System); + + await sut.DeleteDownload(torrent, false); + + await _fixture.ClientWrapper.Received(1).DeleteTorrentAsync("TEST-HASH"); + await _fixture.ClientWrapper.DidNotReceive().StopTorrentAsync(Arg.Any()); + } + + [Fact] + public async Task DeleteFiles_PathAlreadyGone_StillRemovesTorrent() + { + RTorrentService sut = _fixture.CreateSut(); + string missingPath = Path.Combine(Path.GetTempPath(), $"rtorrent-missing-{Guid.NewGuid()}"); + RTorrentItemWrapper torrent = new(new RTorrentTorrent { Hash = "TEST-HASH", Name = "Test", BasePath = missingPath }, null, TimeProvider.System); + + await sut.DeleteDownload(torrent, true); + + await _fixture.ClientWrapper.Received(1).DeleteTorrentAsync("TEST-HASH"); + } + + [Fact] + public async Task DeleteFiles_StopsThenDeletesFilesThenRemovesTorrent() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("rtorrent-delete-order-").FullName; + + try + { + var torrent = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "TEST-HASH", Name = "Test", BasePath = tempDir }, null, TimeProvider.System); + + await sut.DeleteDownload(torrent, true); + + Directory.Exists(tempDir).ShouldBeFalse(); + Received.InOrder(() => + { + _fixture.ClientWrapper.StopTorrentAsync("TEST-HASH"); + _fixture.ClientWrapper.DeleteTorrentAsync("TEST-HASH"); + }); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task FileDeleteFails_ThrowsAndDoesNotRemoveTorrent() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("rtorrent-delete-fail-").FullName; + await File.WriteAllTextAsync(Path.Combine(tempDir, "file.mkv"), "x"); + + // No write permission on the folder itself blocks deleting its entries. + File.SetUnixFileMode(tempDir, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + try + { + var torrent = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "TEST-HASH", Name = "Test", BasePath = tempDir }, null, TimeProvider.System); + + await Should.ThrowAsync(() => sut.DeleteDownload(torrent, true)); + + await _fixture.ClientWrapper.Received(1).StopTorrentAsync("TEST-HASH"); + await _fixture.ClientWrapper.DidNotReceive().DeleteTorrentAsync(Arg.Any()); + } + finally + { + File.SetUnixFileMode(tempDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + Directory.Delete(tempDir, true); + } + } + } + public class BlockUnwantedFilesAsyncScenarios : RTorrentServiceTests { public BlockUnwantedFilesAsyncScenarios(RTorrentServiceFixture fixture) : base(fixture) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdClientTests.cs new file mode 100644 index 00000000..331dbaa8 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdClientTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using Cleanuparr.Domain.Entities.Sabnzbd; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Domain.Exceptions; +using Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +public class SabnzbdClientTests +{ + private static (SabnzbdClient client, FakeHttpMessageHandler handler) CreateClient() + { + FakeHttpMessageHandler handler = new(); + DownloadClientConfig config = new() + { + Name = "sabnzbd", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Host = new Uri("http://localhost:8080"), + ApiKey = "test-api-key", + }; + + return (new SabnzbdClient(config, new HttpClient(handler)), handler); + } + + [Fact] + public async Task ValidateApiKeyAsync_AcceptedKey_RequestsQueueMode() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"queue":{"slots":[]}}""") + })); + + await client.ValidateApiKeyAsync(); + + handler.CapturedRequests[0].RequestUri!.Query.ShouldContain("mode=queue"); + } + + [Fact] + public async Task ValidateApiKeyAsync_RejectedKey_ThrowsWithSabnzbdsErrorText() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("API Key Incorrect") + })); + + SabnzbdClientException exception = await Should.ThrowAsync(() => client.ValidateApiKeyAsync()); + + exception.Message.ShouldContain("API Key Incorrect"); + } + + [Fact] + public async Task GetQueueAsync_StatusFalseWithHttp200_ThrowsWithSabnzbdsErrorText() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"status":false,"error":"boom"}""") + })); + + SabnzbdClientException exception = await Should.ThrowAsync(() => client.GetQueueAsync()); + + exception.Message.ShouldContain("boom"); + } + + [Fact] + public async Task GetHistoryAsync_StatusFalseWithHttp200_ThrowsWithSabnzbdsErrorText() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"status":false,"error":"boom"}""") + })); + + SabnzbdClientException exception = await Should.ThrowAsync(() => client.GetHistoryAsync()); + + exception.Message.ShouldContain("boom"); + } + + [Fact] + public async Task GetQueueAsync_NormalResponse_ParsesQueue() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"queue":{"slots":[]}}""") + })); + + SabnzbdQueueData? queue = await client.GetQueueAsync(); + + queue.ShouldNotBeNull(); + } + + [Fact] + public async Task DeleteFromQueueAsync_StatusFalse_ThrowsWithSabnzbdsErrorText() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"status":false,"error":"boom"}""") + })); + + SabnzbdClientException exception = await Should.ThrowAsync(() => client.DeleteFromQueueAsync("SABnzbd_nzo_1", false)); + + exception.Message.ShouldContain("boom"); + } + + [Fact] + public async Task DeleteFromQueueAsync_StatusTrue_DoesNotThrow() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"status":true}""") + })); + + await Should.NotThrowAsync(() => client.DeleteFromQueueAsync("SABnzbd_nzo_1", false)); + } + + [Fact] + public async Task GetQueueAsync_RequestTimesOut_ThrowsWithModeContext() + { + (SabnzbdClient client, FakeHttpMessageHandler handler) = CreateClient(); + handler.SetupThrow(new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout")); + + SabnzbdClientException exception = await Should.ThrowAsync(() => client.GetQueueAsync()); + + exception.Message.ShouldContain("SABnzbd request failed for mode 'queue'"); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdServiceFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdServiceFixture.cs new file mode 100644 index 00000000..593ff2e7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdServiceFixture.cs @@ -0,0 +1,91 @@ +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Http; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Persistence.Models.Configuration; +using Microsoft.Extensions.Logging; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using NSubstitute; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +public class SabnzbdServiceFixture : IDisposable +{ + public ILogger Logger { get; private set; } + public IFilenameEvaluator FilenameEvaluator { get; private set; } + public IDryRunInterceptor DryRunInterceptor { get; private set; } + public IDynamicHttpClientProvider HttpClientProvider { get; private set; } + public IEventPublisher EventPublisher { get; private set; } + public IBlocklistProvider BlocklistProvider { get; private set; } + public ISabnzbdClientWrapper ClientWrapper { get; private set; } + + public SabnzbdServiceFixture() + { + SubstituteHelper.ClearPendingArgSpecs(); + Logger = Substitute.For>(); + FilenameEvaluator = Substitute.For(); + DryRunInterceptor = Substitute.For(); + HttpClientProvider = Substitute.For(); + EventPublisher = Substitute.For(); + BlocklistProvider = Substitute.For(); + ClientWrapper = Substitute.For(); + + DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); + } + + public SabnzbdService CreateSut(DownloadClientConfig? config = null) + { + config ??= new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "Test Client", + TypeName = Domain.Enums.DownloadClientTypeName.Sabnzbd, + Type = Domain.Enums.DownloadClientType.Usenet, + Enabled = true, + Host = new Uri("http://localhost:8080"), + ApiKey = "test-api-key", + UrlBase = "" + }; + + var httpClient = new HttpClient(); + HttpClientProvider + .CreateClient(Arg.Any()) + .Returns(httpClient); + + return new SabnzbdService( + Logger, + FilenameEvaluator, + DryRunInterceptor, + HttpClientProvider, + EventPublisher, + BlocklistProvider, + config, + TimeProvider.System, + ClientWrapper + ); + } + + public void ResetMocks() + { + SubstituteHelper.ClearPendingArgSpecs(); + Logger = Substitute.For>(); + FilenameEvaluator = Substitute.For(); + DryRunInterceptor = Substitute.For(); + HttpClientProvider = Substitute.For(); + EventPublisher = Substitute.For(); + BlocklistProvider = Substitute.For(); + ClientWrapper = Substitute.For(); + + DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => callInfo.ArgAt>(0).Invoke()); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdServiceTests.cs new file mode 100644 index 00000000..6fa1c628 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/SabnzbdServiceTests.cs @@ -0,0 +1,1130 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Entities.Sabnzbd; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Domain.Exceptions; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient; + +public class SabnzbdServiceTests : IClassFixture +{ + private readonly SabnzbdServiceFixture _fixture; + + public SabnzbdServiceTests(SabnzbdServiceFixture fixture) + { + _fixture = fixture; + _fixture.ResetMocks(); + } + + public class ShouldRemoveFromArrQueueAsync_Scenarios : SabnzbdServiceTests + { + public ShouldRemoveFromArrQueueAsync_Scenarios(SabnzbdServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task NotFoundInQueueOrHistory_ReturnsEmptyResult() + { + const string nzoId = "SABnzbd_nzo_missing"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns((SabnzbdQueueData?)null); + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns((SabnzbdHistoryData?)null); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeFalse(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + } + + [Fact] + public async Task FoundInHistoryAsFailed_RemovesWithDownloadFailedReason_DeletesFromClient() + { + const string nzoId = "SABnzbd_nzo_failed"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns((SabnzbdQueueData?)null); + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns(new SabnzbdHistoryData + { + Slots = + [ + new SabnzbdHistorySlot { NzoId = nzoId, Name = "Broken.Release", Status = "Failed", FailMessage = "not enough articles" } + ] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.IsPrivate.ShouldBeFalse(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.DownloadFailed); + result.DeleteFromClient.ShouldBeTrue(); + } + + [Fact] + public async Task CompletedInHistory_DoesNotRemove() + { + const string nzoId = "SABnzbd_nzo_completed"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns((SabnzbdQueueData?)null); + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns(new SabnzbdHistoryData + { + Slots = [new SabnzbdHistorySlot { NzoId = nzoId, Name = "Good.Release", Status = "Completed", Storage = "/downloads/Good.Release" }] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + } + + [Fact] + public async Task IndividuallyPaused_NeverStrikes() + { + // A job paused on its own (not via a global pause) is still a deliberate action, not a stall. + const string nzoId = "SABnzbd_nzo_paused"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns(new SabnzbdQueueData + { + KbPerSec = 0, + Slots = [new SabnzbdQueueSlot { NzoId = nzoId, Filename = "Paused.Release", Status = "Paused", Mb = 100, MbLeft = 50 }] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper.DidNotReceive().GetHistoryAsync(Arg.Any()); + } + + [Fact] + public async Task QueueGloballyPaused_ItemWithPartialProgress_NeverStrikes() + { + // A global pause leaves the slot's own status unchanged (still "Queued"), but a deliberate pause must never strike. + const string nzoId = "SABnzbd_nzo_globally_paused_active"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns(new SabnzbdQueueData + { + Paused = true, + Slots = [new SabnzbdQueueSlot { NzoId = nzoId, Filename = "Paused.Release", Status = "Queued", Mb = 1715.94, MbLeft = 287.42 }] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + } + + [Fact] + public async Task StatusDownloadingWithZeroThroughput_DoesNotRemove() + { + // SABnzbd fails a job that stops progressing on its own; a queue item is never struck directly. + const string nzoId = "SABnzbd_nzo_zero_throughput"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns(new SabnzbdQueueData + { + KbPerSec = 0, + Slots = [new SabnzbdQueueSlot { NzoId = nzoId, Filename = "Stuck.Release", Status = "Downloading", Mb = 1000, MbLeft = 400 }] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + } + + [Fact] + public async Task QueuedDownloadingWithSpeed_DoesNotRemove() + { + const string nzoId = "SABnzbd_nzo_downloading"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns(new SabnzbdQueueData + { + KbPerSec = 500, + Slots = [new SabnzbdQueueSlot { NzoId = nzoId, Filename = "Downloading.Release", Status = "Downloading", Mb = 1000, MbLeft = 400 }] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + } + + [Fact] + public async Task IgnoredByHash_ReturnsFoundButNotRemoved() + { + const string nzoId = "SABnzbd_nzo_ignored"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns(new SabnzbdQueueData + { + Slots = [new SabnzbdQueueSlot { NzoId = nzoId, Filename = "Ignored.Release", Status = "Downloading", Mb = 100, MbLeft = 50 }] + }); + + var result = await sut.ShouldRemoveFromArrQueueAsync(nzoId, [nzoId]); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + } + } + + public class GetClaimedPathsAsync_Scenarios : SabnzbdServiceTests + { + public GetClaimedPathsAsync_Scenarios(SabnzbdServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task HistoryItem_ClaimsStoragePath() + { + var sut = _fixture.CreateSut(); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = "/downloads/complete/Test" }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain("/downloads/complete/Test"); + } + + [Fact] + public async Task HistoryItem_RemapsStoragePath() + { + string targetRoot = Directory.CreateTempSubdirectory("sabnzbd-claim-test-").FullName; + + try + { + DownloadClientConfig config = new() + { + Id = Guid.NewGuid(), + Name = "Test Client", + TypeName = Domain.Enums.DownloadClientTypeName.Sabnzbd, + Type = Domain.Enums.DownloadClientType.Usenet, + Enabled = true, + Host = new Uri("http://localhost:8080"), + ApiKey = "test-api-key", + UrlBase = "", + DownloadDirectorySource = "/downloads", + DownloadDirectoryTarget = targetRoot, + }; + var sut = _fixture.CreateSut(config); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = "/downloads/complete/Test" }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain(Path.Combine(targetRoot, "complete", "Test")); + } + finally + { + Directory.Delete(targetRoot, true); + } + } + + [Fact] + public async Task BusyQueue_ClaimsDownloadDirEntriesAndCompleteDir() + { + // SAB can rename a folder on a clash, so a busy queue claims every entry, not just name lookalikes. + string downloadDir = Directory.CreateTempSubdirectory("sabnzbd-incomplete-").FullName; + string completeDir = Directory.CreateTempSubdirectory("sabnzbd-complete-").FullName; + + try + { + string jobFolder = Directory.CreateDirectory(Path.Combine(downloadDir, "Downloading.Release")).FullName; + string renamedFolder = Directory.CreateDirectory(Path.Combine(downloadDir, "Downloading.Release.1")).FullName; + + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns(downloadDir); + _fixture.ClientWrapper.GetCompleteDirAsync().Returns(completeDir); + var item = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Downloading.Release", Status = "Downloading", Mb = 100, MbLeft = 50 }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain(downloadDir); + claimed.ShouldContain(jobFolder); + claimed.ShouldContain(renamedFolder); + claimed.ShouldContain(completeDir); + } + finally + { + Directory.Delete(downloadDir, true); + Directory.Delete(completeDir, true); + } + } + + [Fact] + public async Task EmptyQueue_ClaimsNothingFromDownloadDir() + { + // Nothing is downloading, so download_dir leftovers count as orphans, not claims. + string downloadDir = Directory.CreateTempSubdirectory("sabnzbd-incomplete-").FullName; + + try + { + Directory.CreateDirectory(Path.Combine(downloadDir, "Leftover.Release")); + + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns(downloadDir); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = "/downloads/complete/Test" }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldNotContain(Path.Combine(downloadDir, "Leftover.Release")); + } + finally + { + Directory.Delete(downloadDir, true); + } + } + + [Fact] + public async Task HistoryOnlyPostProcessing_ClaimsEveryEntryInDownloadDir() + { + // SAB moves a job to history before post-processing finishes, so an empty queue with a busy history item must still claim. + string downloadDir = Directory.CreateTempSubdirectory("sabnzbd-incomplete-").FullName; + + try + { + string jobFolder = Directory.CreateDirectory(Path.Combine(downloadDir, "Extracting.Release")).FullName; + + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns(downloadDir); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Extracting.Release", Status = "Extracting" }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain(jobFolder); + } + finally + { + Directory.Delete(downloadDir, true); + } + } + + [Fact] + public async Task HistoryOnlyCompleted_ClaimsNothingFromDownloadDir() + { + string downloadDir = Directory.CreateTempSubdirectory("sabnzbd-incomplete-").FullName; + + try + { + Directory.CreateDirectory(Path.Combine(downloadDir, "Leftover.Release")); + + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns(downloadDir); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = "/downloads/complete/Test" }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldNotContain(Path.Combine(downloadDir, "Leftover.Release")); + } + finally + { + Directory.Delete(downloadDir, true); + } + } + + [Fact] + public async Task BusyQueue_DownloadDirMissingOnDisk_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns("/does/not/exist/on/disk"); + var item = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Downloading.Release", Status = "Downloading", Mb = 100, MbLeft = 50 }); + + await Should.ThrowAsync(() => sut.GetClaimedPathsAsync([item])); + } + + [Fact] + public async Task BusyQueue_EmptyDownloadDir_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns((string?)null); + var item = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Downloading.Release", Status = "Downloading", Mb = 100, MbLeft = 50 }); + + await Should.ThrowAsync(() => sut.GetClaimedPathsAsync([item])); + } + + [Fact] + public async Task BusyQueue_RelativeDownloadDir_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns("incomplete"); + var item = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Downloading.Release", Status = "Downloading", Mb = 100, MbLeft = 50 }); + + await Should.ThrowAsync(() => sut.GetClaimedPathsAsync([item])); + } + + [Fact] + public async Task IdleClient_UnresolvableDownloadDirAndCompleteDir_SkipsBothWithoutThrowing() + { + // An idle client never needs BuildIncompleteClaimsAsync's throwing resolve, so a missing dir is skipped. + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns((string?)null); + _fixture.ClientWrapper.GetCompleteDirAsync().Returns("/does/not/exist/on/disk"); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = "/downloads/complete/Test" }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain("/downloads/complete/Test"); + claimed.ShouldNotContain("/does/not/exist/on/disk"); + } + + [Fact] + public async Task IdleClient_EmptyHistory_ClaimsDownloadDirAndCompleteDirOnly() + { + string downloadDir = Directory.CreateTempSubdirectory("sabnzbd-incomplete-").FullName; + string completeDir = Directory.CreateTempSubdirectory("sabnzbd-complete-").FullName; + + try + { + string leftover = Directory.CreateDirectory(Path.Combine(downloadDir, "Leftover.Release")).FullName; + + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns(downloadDir); + _fixture.ClientWrapper.GetCompleteDirAsync().Returns(completeDir); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([]); + + claimed.ShouldContain(downloadDir); + claimed.ShouldContain(completeDir); + claimed.ShouldNotContain(leftover); + } + finally + { + Directory.Delete(downloadDir, true); + Directory.Delete(completeDir, true); + } + } + + [Fact] + public async Task BusyQueue_WindowsDownloadDirRemappedToExistingLinuxDir_ClaimsItsEntries() + { + // Remap happens before the rooted check, so a Windows SAB dir resolves through a configured remap. + string targetRoot = Directory.CreateTempSubdirectory("sabnzbd-incomplete-remap-").FullName; + + try + { + string jobFolder = Directory.CreateDirectory(Path.Combine(targetRoot, "Downloading.Release")).FullName; + + DownloadClientConfig config = new() + { + Id = Guid.NewGuid(), + Name = "Test Client", + TypeName = Domain.Enums.DownloadClientTypeName.Sabnzbd, + Type = Domain.Enums.DownloadClientType.Usenet, + Enabled = true, + Host = new Uri("http://localhost:8080"), + ApiKey = "test-api-key", + UrlBase = "", + DownloadDirectorySource = "D:\\incomplete", + DownloadDirectoryTarget = targetRoot, + }; + var sut = _fixture.CreateSut(config); + _fixture.ClientWrapper.GetDownloadDirAsync().Returns("D:\\incomplete"); + var item = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Downloading.Release", Status = "Downloading", Mb = 100, MbLeft = 50 }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain(jobFolder); + } + finally + { + Directory.Delete(targetRoot, true); + } + } + + [Fact] + public async Task CategorySubfolderHistoryItem_ClaimsOnlyTheJobFolder() + { + // The ancestors above the job folder (complete/tv, complete) are no longer claimed directly here; + // the orphan scanner's ancestor-of-a-claim rule keeps them instead. + string targetRoot = Directory.CreateTempSubdirectory("sabnzbd-claim-test-").FullName; + + try + { + DownloadClientConfig config = new() + { + Id = Guid.NewGuid(), + Name = "Test Client", + TypeName = Domain.Enums.DownloadClientTypeName.Sabnzbd, + Type = Domain.Enums.DownloadClientType.Usenet, + Enabled = true, + Host = new Uri("http://localhost:8080"), + ApiKey = "test-api-key", + UrlBase = "", + DownloadDirectorySource = "/downloads", + DownloadDirectoryTarget = targetRoot, + }; + var sut = _fixture.CreateSut(config); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot + { + NzoId = "nzo1", Name = "Job", Status = "Completed", Storage = "/downloads/complete/tv/Job" + }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain(Path.Combine(targetRoot, "complete", "tv", "Job")); + claimed.ShouldNotContain(Path.Combine(targetRoot, "complete", "tv")); + claimed.ShouldNotContain(Path.Combine(targetRoot, "complete")); + } + finally + { + Directory.Delete(targetRoot, true); + } + } + + [Fact] + public async Task SingleFileHistoryItem_ClaimsOnlyTheStorageFile() + { + // A single-file job reports `storage` as the file itself; its job folder is left to the scanner's + // ancestor-of-a-claim rule rather than claimed here. + string targetRoot = Directory.CreateTempSubdirectory("sabnzbd-claim-test-").FullName; + + try + { + DownloadClientConfig config = new() + { + Id = Guid.NewGuid(), + Name = "Test Client", + TypeName = Domain.Enums.DownloadClientTypeName.Sabnzbd, + Type = Domain.Enums.DownloadClientType.Usenet, + Enabled = true, + Host = new Uri("http://localhost:8080"), + ApiKey = "test-api-key", + UrlBase = "", + DownloadDirectorySource = "/downloads", + DownloadDirectoryTarget = targetRoot, + }; + var sut = _fixture.CreateSut(config); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot + { + NzoId = "nzo1", Name = "Job", Status = "Completed", Storage = "/downloads/complete/Job/file.mkv" + }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain(Path.Combine(targetRoot, "complete", "Job", "file.mkv")); + claimed.ShouldNotContain(Path.Combine(targetRoot, "complete", "Job")); + } + finally + { + Directory.Delete(targetRoot, true); + } + } + + [Fact] + public async Task AbsoluteCategoryHistoryItem_ClaimsTheJobFolderOutsideCompleteDir() + { + // No download_dir mapping covers this storage path, so it needs no remapping. + var sut = _fixture.CreateSut(); + var item = new SabnzbdItemWrapper(new SabnzbdHistorySlot + { + NzoId = "nzo1", Name = "Job", Status = "Completed", Storage = "/data/tv/Job" + }); + + IReadOnlyList claimed = await sut.GetClaimedPathsAsync([item]); + + claimed.ShouldContain("/data/tv/Job"); + } + } + + public class GetAllDownloadsLite_Scenarios : SabnzbdServiceTests + { + public GetAllDownloadsLite_Scenarios(SabnzbdServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task NullQueue_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetQueueAsync().Returns((SabnzbdQueueData?)null); + _fixture.ClientWrapper.GetHistoryAsync().Returns(new SabnzbdHistoryData { Slots = [] }); + + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); + } + + [Fact] + public async Task NullHistory_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetQueueAsync().Returns(new SabnzbdQueueData { Slots = [] }); + _fixture.ClientWrapper.GetHistoryAsync().Returns((SabnzbdHistoryData?)null); + + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); + } + + [Fact] + public async Task BlankNzoId_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetQueueAsync().Returns(new SabnzbdQueueData + { + Slots = [new SabnzbdQueueSlot { NzoId = "", Filename = "Blank.Release", Status = "Downloading" }] + }); + _fixture.ClientWrapper.GetHistoryAsync().Returns(new SabnzbdHistoryData { Slots = [] }); + + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); + } + + [Fact] + public async Task HealthyResponse_ReturnsEveryRow() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetQueueAsync().Returns(new SabnzbdQueueData + { + Slots = [new SabnzbdQueueSlot { NzoId = "nzo1", Filename = "Queued.Release", Status = "Downloading" }] + }); + _fixture.ClientWrapper.GetHistoryAsync().Returns(new SabnzbdHistoryData + { + Slots = [new SabnzbdHistorySlot { NzoId = "nzo2", Name = "Done.Release", Status = "Completed" }] + }); + + List items = await sut.GetAllDownloadsLite(); + + items.Count.ShouldBe(2); + items.Select(x => x.DownloadId).ShouldBe(["nzo1", "nzo2"], ignoreOrder: true); + } + + [Fact] + public async Task HealthyResponse_ReadsQueueBeforeHistory() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.GetQueueAsync().Returns(new SabnzbdQueueData { Slots = [] }); + _fixture.ClientWrapper.GetHistoryAsync().Returns(new SabnzbdHistoryData { Slots = [] }); + + await sut.GetAllDownloadsLite(); + + Received.InOrder(() => + { + _fixture.ClientWrapper.GetQueueAsync(); + _fixture.ClientWrapper.GetHistoryAsync(); + }); + } + } + + public class DeleteDownload_Scenarios : SabnzbdServiceTests + { + public DeleteDownload_Scenarios(SabnzbdServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task HistoryItem_CallsDeleteFromHistoryAsync() + { + var sut = _fixture.CreateSut(); + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed" }); + + await sut.DeleteDownload(torrent, true); + + await _fixture.ClientWrapper.Received(1).DeleteFromHistoryAsync("nzo1", true); + await _fixture.ClientWrapper.DidNotReceive().DeleteFromQueueAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueItem_CallsDeleteFromQueueAsync() + { + var sut = _fixture.CreateSut(); + var torrent = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Test", Status = "Downloading" }); + + await sut.DeleteDownload(torrent, false); + + await _fixture.ClientWrapper.Received(1).DeleteFromQueueAsync("nzo2", false); + await _fixture.ClientWrapper.DidNotReceive().DeleteFromHistoryAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CompletedWithDeleteFiles_DeletesStorageFolderFromDisk() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = tempDir }); + + await sut.DeleteDownload(torrent, true); + + Directory.Exists(tempDir).ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task CompletedSingleFileJob_LastFileInFolder_KeepsTheFolder() + { + // With job folders off, the file sits straight in complete_dir; emptying it must not remove complete_dir. + var sut = _fixture.CreateSut(); + string completeDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + string storagePath = Path.Combine(completeDir, "file.mkv"); + await File.WriteAllTextAsync(storagePath, "x"); + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = storagePath }); + + await sut.DeleteDownload(torrent, true); + + File.Exists(storagePath).ShouldBeFalse(); + Directory.Exists(completeDir).ShouldBeTrue(); + } + finally + { + Directory.Delete(completeDir, true); + } + } + + [Fact] + public async Task CompletedSingleFileJob_SharedParentWithSiblingFile_DeletesOnlyTheJobFile() + { + // SAB sorting can put another job's file in the same folder; the folder must survive. + var sut = _fixture.CreateSut(); + string sharedParent = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + string storagePath = Path.Combine(sharedParent, "file.mkv"); + string siblingPath = Path.Combine(sharedParent, "other-job.mkv"); + await File.WriteAllTextAsync(storagePath, "x"); + await File.WriteAllTextAsync(siblingPath, "y"); + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = storagePath }); + + await sut.DeleteDownload(torrent, true); + + File.Exists(storagePath).ShouldBeFalse(); + File.Exists(siblingPath).ShouldBeTrue(); + Directory.Exists(sharedParent).ShouldBeTrue(); + } + finally + { + Directory.Delete(sharedParent, true); + } + } + + [Theory] + [InlineData("Test")] + [InlineData("Test.1")] + public async Task CompletedSingleFileJob_EmptiedJobFolder_RemovesTheFolder(string jobFolderName) + { + SabnzbdService sut = _fixture.CreateSut(); + string completeDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + string jobFolder = Path.Combine(completeDir, jobFolderName); + string storagePath = Path.Combine(jobFolder, "file.mkv"); + Directory.CreateDirectory(jobFolder); + await File.WriteAllTextAsync(storagePath, "x"); + + try + { + SabnzbdItemWrapper torrent = new(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = storagePath }); + + await sut.DeleteDownload(torrent, true); + + Directory.Exists(jobFolder).ShouldBeFalse(); + Directory.Exists(completeDir).ShouldBeTrue(); + } + finally + { + Directory.Delete(completeDir, true); + } + } + + [Fact] + public async Task CompletedSingleFileJob_JobFolderWithLeftoverFile_KeepsTheFolder() + { + SabnzbdService sut = _fixture.CreateSut(); + string completeDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + string jobFolder = Path.Combine(completeDir, "Test"); + string storagePath = Path.Combine(jobFolder, "file.mkv"); + string leftoverPath = Path.Combine(jobFolder, "file.nfo"); + Directory.CreateDirectory(jobFolder); + await File.WriteAllTextAsync(storagePath, "x"); + await File.WriteAllTextAsync(leftoverPath, "y"); + + try + { + SabnzbdItemWrapper torrent = new(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = storagePath }); + + await sut.DeleteDownload(torrent, true); + + File.Exists(storagePath).ShouldBeFalse(); + File.Exists(leftoverPath).ShouldBeTrue(); + } + finally + { + Directory.Delete(completeDir, true); + } + } + + [Fact] + public async Task CompletedSingleFileJob_NoJobName_KeepsTheFolder() + { + // An empty name prefixes every folder, so it must not count as a match. + SabnzbdService sut = _fixture.CreateSut(); + string completeDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + string storagePath = Path.Combine(completeDir, "file.mkv"); + await File.WriteAllTextAsync(storagePath, "x"); + + try + { + SabnzbdItemWrapper torrent = new(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "", Status = "Completed", Storage = storagePath }); + + await sut.DeleteDownload(torrent, true); + + File.Exists(storagePath).ShouldBeFalse(); + Directory.Exists(completeDir).ShouldBeTrue(); + } + finally + { + Directory.Delete(completeDir, true); + } + } + + [Fact] + public async Task FailedStatus_DoesNotDeleteFromDisk() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Failed", Storage = tempDir }); + + await sut.DeleteDownload(torrent, true); + + Directory.Exists(tempDir).ShouldBeTrue(); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task DeleteFilesFalse_DoesNotDeleteFromDisk() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = tempDir }); + + await sut.DeleteDownload(torrent, false); + + Directory.Exists(tempDir).ShouldBeTrue(); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task QueueItem_NeverDeletesFromDisk() + { + var sut = _fixture.CreateSut(); + var torrent = new SabnzbdItemWrapper(new SabnzbdQueueSlot { NzoId = "nzo2", Filename = "Test", Status = "Downloading" }); + + await sut.DeleteDownload(torrent, true); + + await _fixture.DryRunInterceptor.DidNotReceive().InterceptAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task MissingStoragePath_DoesNotThrow() + { + var sut = _fixture.CreateSut(); + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = null }); + + await Should.NotThrowAsync(() => sut.DeleteDownload(torrent, true)); + + await _fixture.ClientWrapper.Received(1).DeleteFromHistoryAsync("nzo1", true); + } + + [Fact] + public async Task CompletedWithDeleteFiles_DeletesFilesBeforeClearingHistory() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-delete-order-").FullName; + bool filesGoneBeforeHistoryDelete = false; + + _fixture.ClientWrapper + .DeleteFromHistoryAsync("nzo1", true) + .Returns(_ => + { + filesGoneBeforeHistoryDelete = !Directory.Exists(tempDir); + return Task.CompletedTask; + }); + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = tempDir }); + + await sut.DeleteDownload(torrent, true); + + filesGoneBeforeHistoryDelete.ShouldBeTrue(); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task FileDeleteFails_ThrowsAndDoesNotClearHistory() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-delete-fail-").FullName; + await File.WriteAllTextAsync(Path.Combine(tempDir, "file.mkv"), "x"); + + // No write permission on the folder itself blocks deleting its entries. + File.SetUnixFileMode(tempDir, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = tempDir }); + + await Should.ThrowAsync(() => sut.DeleteDownload(torrent, true)); + + await _fixture.ClientWrapper.DidNotReceive().DeleteFromHistoryAsync(Arg.Any(), Arg.Any()); + } + finally + { + File.SetUnixFileMode(tempDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task DryRun_SkipsDiskDelete_ButStillDeletesFromHistory() + { + var sut = _fixture.CreateSut(); + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-delete-test-").FullName; + + try + { + var torrent = new SabnzbdItemWrapper(new SabnzbdHistorySlot { NzoId = "nzo1", Name = "Test", Status = "Completed", Storage = tempDir }); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + await sut.DeleteDownload(torrent, true); + + Directory.Exists(tempDir).ShouldBeTrue(); + await _fixture.ClientWrapper.Received(1).DeleteFromHistoryAsync("nzo1", true); + } + finally + { + Directory.Delete(tempDir, true); + } + } + } + + public class BlockUnwantedFilesAsync_Scenarios : SabnzbdServiceTests + { + public BlockUnwantedFilesAsync_Scenarios(SabnzbdServiceFixture fixture) : base(fixture) + { + } + + private void SetMalwareBlockerContext(ContentBlockerConfig? config = null) + { + ContextProvider.Set(config ?? new ContentBlockerConfig()); + ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr); + + _fixture.BlocklistProvider + .GetBlocklistType(Arg.Any()) + .Returns(BlocklistType.Blacklist); + _fixture.BlocklistProvider + .GetPatterns(Arg.Any()) + .Returns(new ConcurrentBag()); + _fixture.BlocklistProvider + .GetRegexes(Arg.Any()) + .Returns(new ConcurrentBag()); + } + + [Fact] + public async Task NotInQueueOrHistory_ReturnsNotFound() + { + const string nzoId = "SABnzbd_nzo_missing"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns((SabnzbdQueueData?)null); + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns((SabnzbdHistoryData?)null); + + var result = await sut.BlockUnwantedFilesAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeFalse(); + } + + [Fact] + public async Task StillInQueue_ReturnsFoundWithoutFileScan() + { + const string nzoId = "SABnzbd_nzo_inprogress"; + var sut = _fixture.CreateSut(); + + _fixture.ClientWrapper.GetQueueAsync(nzoId).Returns(new SabnzbdQueueData + { + Slots = [new SabnzbdQueueSlot { NzoId = nzoId, Filename = "Downloading.Release", Status = "Downloading", Mb = 1000, MbLeft = 400 }] + }); + + var result = await sut.BlockUnwantedFilesAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper.DidNotReceive().GetHistoryAsync(Arg.Any()); + } + + [Fact] + public async Task CompletedWithMissingStorageDirectory_ReturnsFoundButNoFileScan() + { + const string nzoId = "SABnzbd_nzo_nodir"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns(new SabnzbdHistoryData + { + Slots = [new SabnzbdHistorySlot { NzoId = nzoId, Name = "Test", Status = "Completed", Storage = "/does/not/exist/on/disk" }] + }); + + var result = await sut.BlockUnwantedFilesAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + } + + [Fact] + public async Task CompletedWithMalwareFile_DeletesFileAndMarksAllFilesBlocked() + { + const string nzoId = "SABnzbd_nzo_malware"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-cb-test-").FullName; + try + { + string malwarePath = Path.Combine(tempDir, "malware.exe"); + await File.WriteAllTextAsync(malwarePath, "dummy"); + + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns(new SabnzbdHistoryData + { + Slots = [new SabnzbdHistorySlot { NzoId = nzoId, Name = "Test", Status = "Completed", Storage = tempDir }] + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + var result = await sut.BlockUnwantedFilesAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + File.Exists(malwarePath).ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task CompletedSingleFileJob_WithMalwareFile_DeletesFileAndMarksAllFilesBlocked() + { + const string nzoId = "SABnzbd_nzo_malware_singlefile"; + SabnzbdService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + string tempDir = Directory.CreateTempSubdirectory("sabnzbd-cb-test-").FullName; + try + { + string malwarePath = Path.Combine(tempDir, "malware.exe"); + await File.WriteAllTextAsync(malwarePath, "dummy"); + + _fixture.ClientWrapper.GetHistoryAsync(nzoId).Returns(new SabnzbdHistoryData + { + Slots = [new SabnzbdHistorySlot { NzoId = nzoId, Name = "Test", Status = "Completed", Storage = malwarePath }] + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(nzoId, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + File.Exists(malwarePath).ShouldBeFalse(); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + } + + public class LoginAndHealthCheck_Scenarios : SabnzbdServiceTests + { + public LoginAndHealthCheck_Scenarios(SabnzbdServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task LoginAsync_ValidatesTheApiKey() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.ValidateApiKeyAsync().Returns(Task.CompletedTask); + + await sut.LoginAsync(); + + await _fixture.ClientWrapper.Received(1).ValidateApiKeyAsync(); + } + + [Fact] + public async Task LoginAsync_RejectedApiKey_Throws() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.ValidateApiKeyAsync().Returns(Task.FromException(new SabnzbdClientException("SABnzbd request failed for mode 'queue': Forbidden API Key Incorrect"))); + + await Should.ThrowAsync(() => sut.LoginAsync()); + } + + [Fact] + public async Task HealthCheckAsync_RejectedApiKey_ReportsUnhealthy() + { + var sut = _fixture.CreateSut(); + _fixture.ClientWrapper.ValidateApiKeyAsync().Returns(Task.FromException(new SabnzbdClientException("SABnzbd request failed for mode 'queue': Forbidden API Key Incorrect"))); + + var result = await sut.HealthCheckAsync(); + + result.IsHealthy.ShouldBeFalse(); + result.ErrorMessage.ShouldContain("API Key Incorrect"); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TorrentListErrorSurfacingTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TorrentListErrorSurfacingTests.cs index e3c0819a..4548cb6d 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TorrentListErrorSurfacingTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TorrentListErrorSurfacingTests.cs @@ -282,7 +282,7 @@ public sealed class TorrentListErrorSurfacingTests QBitService sut = fixture.CreateSut(); fixture.ClientWrapper.GetTorrentListAsync(Arg.Any()).Returns((IReadOnlyList)null!); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -293,7 +293,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetTorrentListAsync(Arg.Any()) .Returns(new List { new() }); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -304,7 +304,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetTorrentListAsync(Arg.Any()) .Returns(new List { new() { Hash = "hash1" }, new() }); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } #endregion @@ -438,7 +438,7 @@ public sealed class TorrentListErrorSurfacingTests new Cleanuparr.Domain.Entities.RTorrent.Response.RTorrentTorrent { Hash = "", Name = "no-hash" }, ]); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -450,7 +450,7 @@ public sealed class TorrentListErrorSurfacingTests new Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus { Hash = "", Name = "no-hash" }, ]); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -461,7 +461,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetStatusForAllTorrents() .Returns((List?)null); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -472,7 +472,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.TorrentGetAsync(Arg.Any(), Arg.Any()) .Returns((Transmission.API.RPC.Entity.TransmissionTorrents?)null); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -486,7 +486,7 @@ public sealed class TorrentListErrorSurfacingTests Torrents = [new Transmission.API.RPC.Entity.TorrentInfo { HashString = "" }], }); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } [Fact] @@ -498,7 +498,7 @@ public sealed class TorrentListErrorSurfacingTests new Cleanuparr.Domain.Entities.UTorrent.Response.UTorrentItem { Hash = "", Name = "no-hash" }, ]); - await Should.ThrowAsync(() => sut.GetAllTorrentsLite()); + await Should.ThrowAsync(() => sut.GetAllDownloadsLite()); } #endregion @@ -516,7 +516,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetAllTorrentsAsync() .Returns(new List()); - List torrents = await sut.GetAllTorrentsLite(); + List torrents = await sut.GetAllDownloadsLite(); torrents.ShouldBeEmpty(); } @@ -529,7 +529,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetStatusForAllTorrents() .Returns(new List()); - List torrents = await sut.GetAllTorrentsLite(); + List torrents = await sut.GetAllDownloadsLite(); torrents.ShouldBeEmpty(); } @@ -542,7 +542,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetTorrentListAsync(Arg.Any()) .Returns(new List()); - List torrents = await sut.GetAllTorrentsLite(); + List torrents = await sut.GetAllDownloadsLite(); torrents.ShouldBeEmpty(); } @@ -555,7 +555,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.TorrentGetAsync(Arg.Any(), Arg.Any()) .Returns(new Transmission.API.RPC.Entity.TransmissionTorrents { Torrents = [] }); - List torrents = await sut.GetAllTorrentsLite(); + List torrents = await sut.GetAllDownloadsLite(); torrents.ShouldBeEmpty(); } @@ -568,7 +568,7 @@ public sealed class TorrentListErrorSurfacingTests fixture.ClientWrapper.GetTorrentsAsync() .Returns(new List()); - List torrents = await sut.GetAllTorrentsLite(); + List torrents = await sut.GetAllDownloadsLite(); torrents.ShouldBeEmpty(); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs index e69e6e7a..6fb0aea2 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionItemWrapperTests.cs @@ -24,7 +24,7 @@ public class TransmissionItemWrapperTests var wrapper = new TransmissionItemWrapper(torrentInfo); // Act - var result = wrapper.Hash; + var result = wrapper.DownloadId; // Assert result.ShouldBe(expectedHash); @@ -38,7 +38,7 @@ public class TransmissionItemWrapperTests var wrapper = new TransmissionItemWrapper(torrentInfo); // Act - var result = wrapper.Hash; + var result = wrapper.DownloadId; // Assert result.ShouldBe(string.Empty); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs index 5c59d94a..a52c5987 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs @@ -49,7 +49,7 @@ public class TransmissionServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -135,7 +135,7 @@ public class UTorrentServiceDCTests : IClassFixture // Assert result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash2"); + result[0].DownloadId.ShouldBe("hash2"); } } @@ -247,7 +247,7 @@ public class UTorrentServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -287,7 +287,7 @@ public class UTorrentServiceDCTests : IClassFixture // Assert result.ShouldNotBeNull(); result.ShouldHaveSingleItem(); - result[0].Hash.ShouldBe("hash1"); + result[0].DownloadId.ShouldBe("hash1"); } [Fact] @@ -342,7 +342,7 @@ public class UTorrentServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .RemoveTorrentsAsync(Arg.Is>(h => h.Contains("test-hash")), true) @@ -363,7 +363,7 @@ public class UTorrentServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "UPPERCASE-HASH"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .RemoveTorrentsAsync(Arg.Any>(), true) @@ -384,7 +384,7 @@ public class UTorrentServiceDCTests : IClassFixture var sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .RemoveTorrentsAsync(Arg.Is>(h => h.Contains("test-hash")), false) @@ -412,7 +412,7 @@ public class UTorrentServiceDCTests : IClassFixture UTorrentService sut = _fixture.CreateSut(); const string hash = "TEST-HASH"; ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .StopTorrentsAsync(Arg.Is>(h => h.Contains("test-hash"))) @@ -433,7 +433,7 @@ public class UTorrentServiceDCTests : IClassFixture UTorrentService sut = _fixture.CreateSut(); const string hash = "UPPERCASE-HASH"; ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns(hash); + mockTorrent.DownloadId.Returns(hash); _fixture.ClientWrapper .StopTorrentsAsync(Arg.Any>()) @@ -453,7 +453,7 @@ public class UTorrentServiceDCTests : IClassFixture // Arrange UTorrentService sut = _fixture.CreateSut(); ITorrentItemWrapper mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("TEST-HASH"); + mockTorrent.DownloadId.Returns("TEST-HASH"); _fixture.ClientWrapper .StopTorrentsAsync(Arg.Any>()) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs index 1d667ec0..3f46fd03 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DeadTorrentServiceTests.cs @@ -19,7 +19,7 @@ public sealed class DeadTorrentServiceTests : IDisposable { private readonly DataContext _dataContext; private readonly IStriker _striker; - private readonly IDownloadService _downloadService; + private readonly IDeadTorrentCapable _downloadService; private readonly DownloadClientConfig _clientConfig; private readonly FakeTimeProvider _timeProvider; private readonly DeadTorrentService _sut; @@ -28,7 +28,7 @@ public sealed class DeadTorrentServiceTests : IDisposable { _dataContext = TestDataContextFactory.Create(seedData: false); _striker = Substitute.For(); - _downloadService = Substitute.For(); + _downloadService = Substitute.For(); _timeProvider = new FakeTimeProvider(); _clientConfig = new DownloadClientConfig @@ -77,7 +77,7 @@ public sealed class DeadTorrentServiceTests : IDisposable DateTimeOffset? addedOn = null) { var torrent = Substitute.For(); - torrent.Hash.Returns(hash); + torrent.DownloadId.Returns(hash); torrent.Name.Returns($"Test {hash}"); torrent.Category.Returns(category); torrent.SeederCount.Returns(seederCount); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs index c151b047..d9799eb4 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs @@ -76,23 +76,23 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable // contributes to "client has torrents" without claiming any test files. private ITorrentItemWrapper DecoyTorrent() => MakeTorrent("decoy", _tempRoot); - private IDownloadService SetupDownloadService(DownloadClientConfig clientConfig, List torrents) + private IMockDownloadService SetupDownloadService(DownloadClientConfig clientConfig, List torrents) { - var svc = Substitute.For(); + var svc = Substitute.For(); svc.ClientConfig.Returns(clientConfig); svc.LoginAsync().Returns(Task.CompletedTask); svc.GetSeedingDownloads().Returns([]); - svc.GetAllTorrentsLite().Returns(torrents); - svc.GetClaimedPathsAsync(Arg.Any>()) - .Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg>()))); + svc.GetAllDownloadsLite().Returns(torrents); + svc.GetClaimedPathsAsync(Arg.Any>()) + .Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg>()))); _fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc); return svc; } - private static IReadOnlyList BuildDefaultClaimedPaths(IReadOnlyList torrents) + private static IReadOnlyList BuildDefaultClaimedPaths(IReadOnlyList torrents) { HashSet paths = new(StringComparer.OrdinalIgnoreCase); - foreach (ITorrentItemWrapper torrent in torrents) + foreach (IDownloadItem torrent in torrents) { if (string.IsNullOrEmpty(torrent.SavePath)) { @@ -296,6 +296,58 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable File.Exists(strayInsideClaimed).ShouldBeTrue(); } + [Fact] + public async Task OrphanedFiles_EntryHoldsClaimedDescendant_StaysWhileUnclaimedSiblingMoves() + { + string scanDir = Path.Combine(_tempRoot, "downloads"); + string ancestorEntry = Path.Combine(scanDir, "tv"); + string claimedDir = Path.Combine(ancestorEntry, "claimed-show"); + Directory.CreateDirectory(claimedDir); + string unclaimedSibling = Path.Combine(scanDir, "unclaimed.mkv"); + File.WriteAllText(unclaimedSibling, "x"); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: Path.Combine(_tempRoot, "orphaned")); + + SetupDownloadService(dbClient, [MakeTorrent("", claimedDir)]); + + DownloadCleaner sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + Directory.Exists(ancestorEntry).ShouldBeTrue(); + File.Exists(unclaimedSibling).ShouldBeFalse(); + } + + [Fact] + public async Task OrphanedFiles_PrefixTrap_SimilarlyNamedSiblingStillMoves() + { + string scanDir = Path.Combine(_tempRoot, "downloads"); + string tv = Path.Combine(scanDir, "tv"); + string tv2 = Path.Combine(scanDir, "tv2"); + string claimedDir = Path.Combine(tv2, "x"); + Directory.CreateDirectory(tv); + Directory.CreateDirectory(claimedDir); + + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First(); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, dbClient, + scanDirectories: [scanDir], + orphanedDirectory: Path.Combine(_tempRoot, "orphaned")); + + SetupDownloadService(dbClient, [MakeTorrent("", claimedDir)]); + + DownloadCleaner sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + Directory.Exists(tv).ShouldBeFalse(); + Directory.Exists(tv2).ShouldBeTrue(); + } + [SkippableFact] public async Task OrphanedFiles_SymlinkEntry_IsSkipped() { @@ -466,7 +518,7 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable } [Fact] - public async Task OrphanedFiles_DownloadClientThrowsOnGetAllTorrentsLite_ScanIsSkipped() + public async Task OrphanedFiles_DownloadClientThrowsOnGetAllDownloadsLite_ScanIsSkipped() { string scanDir = Path.Combine(_tempRoot, "downloads"); string orphanedDir = Path.Combine(_tempRoot, "orphaned"); @@ -481,11 +533,11 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable scanDirectories: [scanDir], orphanedDirectory: orphanedDir); - IDownloadService svc = Substitute.For(); + IMockDownloadService svc = Substitute.For(); svc.ClientConfig.Returns(dbClient); svc.LoginAsync().Returns(Task.CompletedTask); svc.GetSeedingDownloads().Returns([]); - svc.GetAllTorrentsLite().ThrowsAsync(new HttpRequestException("connection refused")); + svc.GetAllDownloadsLite().ThrowsAsync(new HttpRequestException("connection refused")); _fixture.DownloadServiceFactory.GetDownloadService(dbClient).Returns(svc); DownloadCleaner sut = CreateSut(); @@ -651,11 +703,11 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable scanDirectories: [scanDirB], orphanedDirectory: orphanedDirB); - IDownloadService svcA = Substitute.For(); + IMockDownloadService svcA = Substitute.For(); svcA.ClientConfig.Returns(clientA); svcA.LoginAsync().Returns(Task.CompletedTask); svcA.GetSeedingDownloads().Returns([]); - svcA.GetAllTorrentsLite().ThrowsAsync(new HttpRequestException("connection refused")); + svcA.GetAllDownloadsLite().ThrowsAsync(new HttpRequestException("connection refused")); _fixture.DownloadServiceFactory.GetDownloadService(clientA).Returns(svcA); SetupDownloadService(clientB, []); @@ -693,11 +745,11 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable scanDirectories: [scanDirB], orphanedDirectory: orphanedDirB); - IDownloadService svcA = Substitute.For(); + IMockDownloadService svcA = Substitute.For(); svcA.ClientConfig.Returns(clientA); svcA.LoginAsync().Returns(Task.CompletedTask); svcA.GetSeedingDownloads().Returns([]); - svcA.GetAllTorrentsLite().ThrowsAsync(new HttpRequestException("connection refused")); + svcA.GetAllDownloadsLite().ThrowsAsync(new HttpRequestException("connection refused")); _fixture.DownloadServiceFactory.GetDownloadService(clientA).Returns(svcA); SetupDownloadService(clientB, [DecoyTorrent()]); @@ -710,6 +762,52 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable Directory.GetFiles(orphanedDirB).ShouldContain(f => Path.GetFileName(f) == "b-orphan.mkv"); } + [Fact] + public async Task OrphanedFiles_ClaimedPathsCallThrows_OtherClientStillProcessed() + { + string scanDirA = Path.Combine(_tempRoot, "downloads-a"); + string orphanedDirA = Path.Combine(_tempRoot, "orphaned-a"); + string scanDirB = Path.Combine(_tempRoot, "downloads-b"); + string orphanedDirB = Path.Combine(_tempRoot, "orphaned-b"); + Directory.CreateDirectory(scanDirA); + Directory.CreateDirectory(scanDirB); + string fileInA = Path.Combine(scanDirA, "a-orphan.mkv"); + string fileInB = Path.Combine(scanDirB, "b-orphan.mkv"); + File.WriteAllText(fileInA, "x"); + File.WriteAllText(fileInB, "x"); + + DownloadClientConfig clientA = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client A"); + DownloadClientConfig clientB = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client B"); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, clientA, + scanDirectories: [scanDirA], + orphanedDirectory: orphanedDirA); + TestDataContextFactory.AddOrphanedFilesConfig( + _fixture.DataContext, clientB, + scanDirectories: [scanDirB], + orphanedDirectory: orphanedDirB); + + List torrentsA = [DecoyTorrent()]; + IMockDownloadService svcA = Substitute.For(); + svcA.ClientConfig.Returns(clientA); + svcA.LoginAsync().Returns(Task.CompletedTask); + svcA.GetSeedingDownloads().Returns([]); + svcA.GetAllDownloadsLite().Returns(torrentsA); + svcA.GetClaimedPathsAsync(Arg.Any>()) + .ThrowsAsync(new InvalidOperationException("get_config failed")); + _fixture.DownloadServiceFactory.GetDownloadService(clientA).Returns(svcA); + + SetupDownloadService(clientB, [DecoyTorrent()]); + + DownloadCleaner sut = CreateSut(); + await ExecuteWithTimeAdvance(sut); + + File.Exists(fileInA).ShouldBeTrue(); + File.Exists(fileInB).ShouldBeFalse(); + Directory.GetFiles(orphanedDirB).ShouldContain(f => Path.GetFileName(f) == "b-orphan.mkv"); + _fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Error, "Failed to get torrents").ShouldBeTrue(); + } + [Fact] public async Task OrphanedFiles_ConfigWithEmptyScanDirectories_IsDroppedWithoutTouchingClient() { @@ -720,12 +818,12 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable scanDirectories: [], orphanedDirectory: Path.Combine(_tempRoot, "orphaned")); - IDownloadService svc = SetupDownloadService(dbClient, [DecoyTorrent()]); + IMockDownloadService svc = SetupDownloadService(dbClient, [DecoyTorrent()]); DownloadCleaner sut = CreateSut(); await ExecuteWithTimeAdvance(sut); - await svc.DidNotReceive().GetAllTorrentsLite(); + await svc.DidNotReceive().GetAllDownloadsLite(); _fixture.OrphanedFilesLogger.HasLogContainingAtLeastOnce(LogLevel.Warning, "no scan directories configured").ShouldBeTrue(); } @@ -737,14 +835,14 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable TestDataContextFactory.AddOrphanedFilesConfig(_fixture.DataContext, clientA, scanDirectories: []); TestDataContextFactory.AddOrphanedFilesConfig(_fixture.DataContext, clientB, scanDirectories: []); - IDownloadService svcA = SetupDownloadService(clientA, [DecoyTorrent()]); - IDownloadService svcB = SetupDownloadService(clientB, [DecoyTorrent()]); + IMockDownloadService svcA = SetupDownloadService(clientA, [DecoyTorrent()]); + IMockDownloadService svcB = SetupDownloadService(clientB, [DecoyTorrent()]); DownloadCleaner sut = CreateSut(); await ExecuteWithTimeAdvance(sut); - await svcA.DidNotReceive().GetAllTorrentsLite(); - await svcB.DidNotReceive().GetAllTorrentsLite(); + await svcA.DidNotReceive().GetAllDownloadsLite(); + await svcB.DidNotReceive().GetAllDownloadsLite(); _fixture.OrphanedFilesLogger.HasLogContaining(LogLevel.Warning, "no scan directories configured", count: 2).ShouldBeTrue(); _fixture.OrphanedFilesLogger.HasNoLogContaining(LogLevel.Debug, "claimed paths across all clients").ShouldBeTrue(); } @@ -774,11 +872,11 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable orphanedDirectory: orphanedDir, purgeAfterHours: 24); - IDownloadService svc = Substitute.For(); + IMockDownloadService svc = Substitute.For(); svc.ClientConfig.Returns(dbClient); svc.LoginAsync().Returns(Task.CompletedTask); svc.GetSeedingDownloads().Returns([]); - svc.GetAllTorrentsLite().ThrowsAsync(new HttpRequestException("connection refused")); + svc.GetAllDownloadsLite().ThrowsAsync(new HttpRequestException("connection refused")); _fixture.DownloadServiceFactory.GetDownloadService(dbClient).Returns(svc); DownloadCleaner sut = CreateSut(); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs index 9602eb31..abf091d0 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs @@ -161,11 +161,11 @@ public class DownloadCleanerTests : IDisposable orphanedDirectory: Path.Combine(Path.GetTempPath(), "cleanuparr-tests", Guid.NewGuid().ToString("N"))); // Bound to the persisted client, because the orphaned scan matches configs by client id - IDownloadService mockDownloadService = Substitute.For(); + IMockDownloadService mockDownloadService = Substitute.For(); mockDownloadService.ClientConfig.Returns(client); mockDownloadService.LoginAsync().Returns(Task.CompletedTask); mockDownloadService.GetSeedingDownloads().Returns([]); - mockDownloadService.GetAllTorrentsLite().Returns([]); + mockDownloadService.GetAllDownloadsLite().Returns([]); mockDownloadService.GetClaimedPathsAsync(Arg.Any>()) .Returns(Task.FromResult>([])); @@ -196,7 +196,7 @@ public class DownloadCleanerTests : IDisposable _fixture.DataContext.SaveChanges(); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("ignored-hash"); + mockTorrent.DownloadId.Returns("ignored-hash"); mockTorrent.Name.Returns("Ignored Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(true); @@ -227,7 +227,7 @@ public class DownloadCleanerTests : IDisposable var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("arr-download-hash"); + mockTorrent.DownloadId.Returns("arr-download-hash"); mockTorrent.Name.Returns("Arr Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); @@ -278,7 +278,7 @@ public class DownloadCleanerTests : IDisposable // Need at least one download for arr processing to occur var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -330,7 +330,7 @@ public class DownloadCleanerTests : IDisposable ignoredRootDirs: ["/media/library"]); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -377,7 +377,7 @@ public class DownloadCleanerTests : IDisposable ignoredRootDirs: []); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -439,13 +439,13 @@ public class DownloadCleanerTests : IDisposable _fixture.DataContext.SaveChanges(); var mockTorrent1 = Substitute.For(); - mockTorrent1.Hash.Returns("test-hash-1"); + mockTorrent1.DownloadId.Returns("test-hash-1"); mockTorrent1.Name.Returns("Test Download 1"); mockTorrent1.IsIgnored(Arg.Any>()).Returns(false); mockTorrent1.Category.Returns("completed"); var mockTorrent2 = Substitute.For(); - mockTorrent2.Hash.Returns("test-hash-2"); + mockTorrent2.DownloadId.Returns("test-hash-2"); mockTorrent2.Name.Returns("Test Download 2"); mockTorrent2.IsIgnored(Arg.Any>()).Returns(false); mockTorrent2.Category.Returns("completed"); @@ -519,7 +519,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -558,6 +558,53 @@ public class DownloadCleanerTests : IDisposable #endregion + #region DeadTorrentCapability Tests + + [Fact] + public async Task ExecuteInternalAsync_WhenClientLacksDeadTorrentCapability_SkipsDeadTorrentButStillProcessesUnlinked() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext, typeName: DownloadClientTypeName.rTorrent); + TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext); + + ITorrentItemWrapper mockTorrent = Substitute.For(); + mockTorrent.DownloadId.Returns("test-hash"); + mockTorrent.Name.Returns("Test Download"); + mockTorrent.IsIgnored(Arg.Any>()).Returns(false); + mockTorrent.Category.Returns("completed"); + + DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First(); + + // rTorrent shape: unlinked-capable, no seeder count. + ISeedingCleanupCapable mockDownloadService = Substitute.For(); + IUnlinkedCapable mockDownloadServiceAsUnlinked = (IUnlinkedCapable)mockDownloadService; + mockDownloadService.ClientConfig.Returns(dbClient); + mockDownloadService.LoginAsync().Returns(Task.CompletedTask); + mockDownloadService.GetSeedingDownloads().Returns([mockTorrent]); + mockDownloadServiceAsUnlinked + .FilterDownloadsToChangeCategoryAsync(Arg.Any>(), Arg.Any()) + .Returns([mockTorrent]); + mockDownloadServiceAsUnlinked.CreateCategoryAsync(Arg.Any()).Returns(Task.CompletedTask); + mockDownloadServiceAsUnlinked + .ChangeCategoryForNoHardLinksAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + DownloadCleaner sut = CreateSut(); + + // Act + await ExecuteWithTimeAdvance(sut); + + // Assert - dead-torrent detection is skipped, unlinked processing still runs + await _fixture.DeadTorrentService.DidNotReceiveWithAnyArgs().ProcessAsync(default!, default!); + _fixture.UnlinkedLogger.HasLogContaining(LogLevel.Information, "Evaluating").ShouldBeTrue(); + } + + #endregion + #region CleanDownloadsAsync Tests [Fact] @@ -568,7 +615,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddSeedingRule(_fixture.DataContext, "completed", 1.0, 60); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -619,7 +666,7 @@ public class DownloadCleanerTests : IDisposable // Need at least one download for arr processing to occur var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -711,7 +758,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -750,7 +797,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -792,7 +839,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -837,7 +884,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddSeedingRule(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -876,7 +923,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddSeedingRule(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -922,7 +969,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -977,7 +1024,7 @@ public class DownloadCleanerTests : IDisposable // No seeding rules added — only unlinked config disabled var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -1012,7 +1059,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext, enabled: false); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); @@ -1049,7 +1096,7 @@ public class DownloadCleanerTests : IDisposable TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext, enabled: true, categories: []); var mockTorrent = Substitute.For(); - mockTorrent.Hash.Returns("test-hash"); + mockTorrent.DownloadId.Returns("test-hash"); mockTorrent.Name.Returns("Test Download"); mockTorrent.IsIgnored(Arg.Any>()).Returns(false); mockTorrent.Category.Returns("completed"); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs index df21cf0c..d2578bc7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs @@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.Jobs; @@ -16,7 +17,6 @@ using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Services.Interfaces; -using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; @@ -69,9 +69,9 @@ public class DownloadCleanerIntegrationTests : IDisposable /// /// Creates a mock download service that uses the actual DB config (so seeding rules match by ID). /// - private static IDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig) + private static IMockDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig) { - var mock = Substitute.For(); + var mock = Substitute.For(); mock.ClientConfig.Returns(dbConfig); mock.LoginAsync().Returns(Task.CompletedTask); return mock; @@ -117,7 +117,7 @@ public class DownloadCleanerIntegrationTests : IDisposable // Assert: Only the orphaned download should be passed to filter/clean mockDownloadService.Received().FilterDownloadsToBeCleanedAsync( Arg.Is>(list => - list.Count == 1 && list[0].Hash == orphanedHash), + list.Count == 1 && list[0].DownloadId == orphanedHash), Arg.Any>()); } @@ -157,7 +157,7 @@ public class DownloadCleanerIntegrationTests : IDisposable // Assert: Only the non-ignored download should be processed mockDownloadService.Received().FilterDownloadsToBeCleanedAsync( Arg.Is>(list => - list.Count == 1 && list[0].Hash == "normal_hash"), + list.Count == 1 && list[0].DownloadId == "normal_hash"), Arg.Any>()); } @@ -445,7 +445,7 @@ public class DownloadCleanerIntegrationTests : IDisposable httpClientProvider.CreateClient(Arg.Any()).Returns(new HttpClient()); RecordingDownloadService downloadService = new( - Substitute.For>(), + Substitute.For>(), Substitute.For(), Substitute.For(), _fixture.DryRunInterceptor, @@ -476,12 +476,12 @@ public class DownloadCleanerIntegrationTests : IDisposable /// A real DownloadService, so the cleanup loop runs. /// The client calls are recorded rather than sent. /// - private sealed class RecordingDownloadService : DownloadService + private sealed class RecordingDownloadService : TorrentDownloadService { private readonly List _seedingDownloads; public RecordingDownloadService( - ILogger logger, + ILogger logger, IFilenameEvaluator filenameEvaluator, IStriker striker, IDryRunInterceptor dryRunInterceptor, @@ -505,16 +505,16 @@ public class DownloadCleanerIntegrationTests : IDisposable public List StoppedHashes { get; } = []; - public override Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) + public override Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) { - DeletedHashes.Add(torrent.Hash); + DeletedHashes.Add(item.DownloadId); return Task.CompletedTask; } - public override Task StopDownload(ITorrentItemWrapper torrent) + public override Task StopDownload(IDownloadItem item) { - StoppedHashes.Add(torrent.Hash); + StoppedHashes.Add(item.DownloadId); return Task.CompletedTask; } @@ -530,21 +530,18 @@ public class DownloadCleanerIntegrationTests : IDisposable protected override Task?> GetHardLinkScanItemsAsync( ITorrentItemWrapper torrent) => throw new NotSupportedException(); - public override Task> GetAllTorrentsLite() => Task.FromResult(_seedingDownloads); + public override Task> GetAllDownloadsLite() => + Task.FromResult(_seedingDownloads.Cast().ToList()); - public override Task> GetClaimedPathsAsync(IReadOnlyList torrents) => + public override Task> GetClaimedPathsAsync(IReadOnlyList downloads) => Task.FromResult>([]); public override Task LoginAsync() => Task.CompletedTask; - public override void Dispose() - { - } - public override Task HealthCheckAsync() => throw new NotSupportedException(); public override Task ShouldRemoveFromArrQueueAsync( - string hash, IReadOnlyList ignoredDownloads) => throw new NotSupportedException(); + string downloadId, IReadOnlyList ignoredDownloads) => throw new NotSupportedException(); protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => throw new NotSupportedException(); @@ -552,7 +549,7 @@ public class DownloadCleanerIntegrationTests : IDisposable public override Task CreateCategoryAsync(string name) => throw new NotSupportedException(); public override Task BlockUnwantedFilesAsync( - string hash, IReadOnlyList ignoredDownloads) => throw new NotSupportedException(); + string downloadId, IReadOnlyList ignoredDownloads) => throw new NotSupportedException(); } private static ITorrentItemWrapper CreateMockTorrentItem( @@ -564,7 +561,7 @@ public class DownloadCleanerIntegrationTests : IDisposable bool isStopped = false) { var mock = Substitute.For(); - mock.Hash.Returns(hash); + mock.DownloadId.Returns(hash); mock.Name.Returns(name); mock.Category.Returns(category); mock.Ratio.Returns(ratio); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs index 8d77dd9f..e8fd70a5 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs @@ -204,12 +204,12 @@ public class IntegrationTestFixture : IDisposable /// /// Creates a NSubstitute IDownloadService mock with default configuration. /// - public IDownloadService CreateMockDownloadService( + public IMockDownloadService CreateMockDownloadService( string clientName = "Test qBittorrent", DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent, DownloadClientType type = DownloadClientType.Torrent) { - var mock = Substitute.For(); + var mock = Substitute.For(); mock.ClientConfig.Returns(new DownloadClientConfig { Id = Guid.NewGuid(), diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs index 646d6f16..95078163 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs @@ -464,7 +464,7 @@ public class MalwareBlockerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns([queueRecord]); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync( Arg.Any(), @@ -516,7 +516,7 @@ public class MalwareBlockerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns([packEp2, otherDownload, packEp3]); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) .Returns(new BlockFilesResult { Found = true, ShouldRemove = false }); @@ -804,6 +804,68 @@ public class MalwareBlockerTests : IDisposable _logger.HasLogContaining(LogLevel.Error, "Error checking download").ShouldBeTrue(); } + [Fact] + public async Task ProcessInstanceAsync_WhenFirstClientThrows_NextClientStillFindsTheDownload() + { + // Arrange + DownloadClientConfig configA = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client A"); + DownloadClientConfig configB = TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "Client B"); + EnableSonarrBlocklist(); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + var mockArrClient = Substitute.For(); + mockArrClient.IsRecordValid(Arg.Any()).Returns(true); + mockArrClient.HasContentId(Arg.Any()).Returns(true); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "two-clients-download-id", + Title = "Two Clients Download", + Protocol = "torrent", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueReader + .ReadAllAsync(Arg.Any(), Arg.Any()) + .Returns([queueRecord]); + + // Client load order is not fixed, so whichever client runs first throws. + int calls = 0; + Func> firstThrows = _ => calls++ == 0 + ? throw new Exception("Connection failed") + : Task.FromResult(new BlockFilesResult { Found = true, ShouldRemove = true, DeleteReason = DeleteReason.AllFilesBlocked }); + + var serviceA = _fixture.CreateMockDownloadService("Client A"); + serviceA.BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()).Returns(firstThrows); + var serviceB = _fixture.CreateMockDownloadService("Client B"); + serviceB.BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()).Returns(firstThrows); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Is(x => x.Id == configA.Id)) + .Returns(serviceA); + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Is(x => x.Id == configB.Id)) + .Returns(serviceB); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.HasLogContaining(LogLevel.Error, "Error checking download").ShouldBeTrue(); + QueueItemRemoveRequest request = _fixture.RemovalQueue.ShouldHaveSingle(); + request.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + request.DownloadClient.ShouldNotBeNull(); + calls.ShouldBe(2); + } + [Fact] public async Task ProcessInstanceAsync_LazyLibrarian_PublishesTheEvaluatedDecisions() { @@ -813,9 +875,9 @@ public class MalwareBlockerTests : IDisposable AddArrInstance(InstanceType.LazyLibrarian); ITorrentItemWrapper torrent = Substitute.For(); - torrent.Hash.Returns("torrent-hash"); + torrent.DownloadId.Returns("torrent-hash"); - IDownloadService downloadService = _fixture.CreateMockDownloadService(); + var downloadService = _fixture.CreateMockDownloadService(); LazyLibrarianRemovalDecision decision = new() { @@ -831,7 +893,7 @@ public class MalwareBlockerTests : IDisposable RemoveFromClient = true, DownloadClient = downloadService.ClientConfig, DownloadService = downloadService, - Torrent = torrent, + Download = torrent, }; _fixture.LazyLibrarianServiceCB diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs index 3e56e989..7fd2a8e6 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs @@ -17,6 +17,7 @@ using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; @@ -612,6 +613,74 @@ public class QueueCleanerTests : IDisposable ); } + [Theory] + [InlineData(true, 0)] + [InlineData(false, 1)] + public async Task ProcessInstanceAsync_WhenUsenetDownloadNotFound_HonoursSkipIfNotFoundInClient(bool skipIfNotFound, int expectedChecks) + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Test SABnzbd", DownloadClientTypeName.Sabnzbd); + + QueueCleanerConfig queueCleanerConfig = _fixture.DataContext.QueueCleanerConfigs.First(); + queueCleanerConfig.FailedImport = queueCleanerConfig.FailedImport with { SkipIfNotFoundInClient = skipIfNotFound }; + _fixture.DataContext.SaveChanges(); + + IArrClient mockArrClient = Substitute.For(); + mockArrClient.IsRecordValid(Arg.Any()).Returns(true); + mockArrClient.HasContentId(Arg.Any()).Returns(true); + mockArrClient.ShouldRemoveFromQueue( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(false); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + QueueRecord queueRecord = new() + { + Id = 1, + DownloadId = "SABnzbd_nzo_missing", + Title = "Missing Usenet Download", + Protocol = "usenet", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueReader + .ReadAllAsync(Arg.Any(), Arg.Any()) + .Returns([queueRecord]); + + IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService("Test SABnzbd", DownloadClientTypeName.Sabnzbd); + mockDownloadService + .ShouldRemoveFromArrQueueAsync( + Arg.Any(), + Arg.Any>() + ) + .Returns(new DownloadCheckResult { Found = false }); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + QueueCleanerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + _logger.HasLogContaining(LogLevel.Warning, "Download not found in any usenet client").ShouldBeTrue(); + await mockArrClient.Received(expectedChecks).ShouldRemoveFromQueue( + InstanceType.Sonarr, + queueRecord, + Arg.Any(), + Arg.Any() + ); + } + [Fact] public async Task ProcessInstanceAsync_WhenForceImportDefers_SkipsTheFailedImportCheck() { @@ -645,7 +714,7 @@ public class QueueCleanerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns([queueRecord]); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = false }); @@ -692,7 +761,7 @@ public class QueueCleanerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns(page.ToList()); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = false }); @@ -739,7 +808,7 @@ public class QueueCleanerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns([packEp2, otherDownload, packEp3]); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = false }); @@ -787,7 +856,7 @@ public class QueueCleanerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns([packEp2, otherDownload, packEp3]); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = false }); @@ -838,7 +907,7 @@ public class QueueCleanerTests : IDisposable .ReadAllAsync(Arg.Any(), Arg.Any()) .Returns([packEp2, otherDownload, packEp3]); - IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult @@ -1700,9 +1769,9 @@ public class QueueCleanerTests : IDisposable TestDataContextFactory.AddDownloadClient(_fixture.DataContext); ITorrentItemWrapper torrent = Substitute.For(); - torrent.Hash.Returns("torrent-hash"); + torrent.DownloadId.Returns("torrent-hash"); - IDownloadService downloadService = _fixture.CreateMockDownloadService(); + var downloadService = _fixture.CreateMockDownloadService(); DownloadClientConfig clientConfig = downloadService.ClientConfig; LazyLibrarianRemovalDecision decision = new() @@ -1712,7 +1781,7 @@ public class QueueCleanerTests : IDisposable RemoveFromClient = removeFromClient, DownloadClient = clientConfig, DownloadService = downloadService, - Torrent = torrent, + Download = torrent, }; IReadOnlyList decisions = [decision]; @@ -1785,6 +1854,53 @@ public class QueueCleanerTests : IDisposable _fixture.RemovalQueue.Reader.Count.ShouldBe(0); } + [Fact] + public async Task ProcessInstanceAsync_LazyLibrarian_DeletesAFailedSabnzbdDownloadFromTheClient() + { + // Arrange: a SABnzbd Failed job is a removal decision like any torrent one, but Download is a plain usenet item. + TestDataContextFactory.AddLazyLibrarianInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + IDownloadItem nzbItem = Substitute.For(); + nzbItem.DownloadId.Returns("nzo-hash"); + + var downloadService = _fixture.CreateMockDownloadService(typeName: DownloadClientTypeName.Sabnzbd); + DownloadClientConfig clientConfig = downloadService.ClientConfig; + + LazyLibrarianRemovalDecision decision = new() + { + Item = CreateBookItem() with { DownloadId = "nzo-hash", Source = LazyLibrarianSource.Sabnzbd }, + DeleteReason = DeleteReason.DownloadFailed, + RemoveFromClient = true, + DownloadClient = clientConfig, + DownloadService = downloadService, + Download = nzbItem, + }; + + _fixture.LazyLibrarianServiceQC + .EvaluateAsync(Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns([decision]); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .Returns(downloadService); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(ci => ((Func)ci[0])()); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await downloadService.Received(1).DeleteDownload(nzbItem, true); + QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle(); + r.LazyTarget().RemovedFromClient.ShouldBeTrue(); + r.DeleteReason.ShouldBe(DeleteReason.DownloadFailed); + } + [Fact] public async Task ProcessInstanceAsync_LazyLibrarian_DoesNotUseTheArrQueueReader() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/IMockDownloadService.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/IMockDownloadService.cs new file mode 100644 index 00000000..84adc57b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/IMockDownloadService.cs @@ -0,0 +1,13 @@ +using Cleanuparr.Infrastructure.Features.DownloadClient; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; + +/// +/// Aggregates every capability interface so a single NSubstitute mock can stand in for any torrent client +/// across the job tests, regardless of which capability the job under test actually narrows to. +/// +public interface IMockDownloadService : + IQueueCheckCapable, IFileBlockingCapable, IOrphanClaimsCapable, ISeedingCleanupCapable, IUnlinkedCapable, + IDeadTorrentCapable +{ +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs index 0c4e4a04..68fad125 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs @@ -1,4 +1,5 @@ using System.Threading.Channels; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; @@ -117,15 +118,17 @@ public class JobHandlerFixture : IDisposable /// /// Creates a mock download service /// - public IDownloadService CreateMockDownloadService(string clientName = "Test Client") + public IMockDownloadService CreateMockDownloadService( + string clientName = "Test Client", + Domain.Enums.DownloadClientTypeName typeName = Domain.Enums.DownloadClientTypeName.qBittorrent) { - var mock = Substitute.For(); + var mock = Substitute.For(); mock.ClientConfig.Returns(new Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig { Id = Guid.NewGuid(), Name = clientName, - Type = Domain.Enums.DownloadClientType.Torrent, - TypeName = Domain.Enums.DownloadClientTypeName.qBittorrent, + Type = typeName.ClientType(), + TypeName = typeName, Enabled = true, Host = new Uri("http://localhost:8080") }); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs index 0b4b6bf7..35f0ceee 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs @@ -284,7 +284,7 @@ public static class TestDataContextFactory Id = Guid.NewGuid(), Name = name, TypeName = typeName, - Type = DownloadClientType.Torrent, + Type = typeName.ClientType(), Enabled = enabled, Host = new Uri("http://localhost:8080"), Username = "admin", diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianJobEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianJobEvaluatorTests.cs index a5c9176b..7540f61e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianJobEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianJobEvaluatorTests.cs @@ -4,6 +4,7 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.LazyLibrarian; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; @@ -40,9 +41,9 @@ public class LazyLibrarianJobEvaluatorTests Origin = LazyLibrarianOrigin.New, }; - private static IDownloadService CreateClient(DownloadClientType type = DownloadClientType.Torrent) + private static IMockDownloadService CreateClient(DownloadClientType type = DownloadClientType.Torrent) { - IDownloadService service = Substitute.For(); + IMockDownloadService service = Substitute.For(); service.ClientConfig.Returns(new DownloadClientConfig { Id = Guid.NewGuid(), @@ -64,7 +65,7 @@ public class LazyLibrarianJobEvaluatorTests { // Arrange: an usenet client cannot hold a LazyLibrarian torrent snatch. StubQueue(CreateItem()); - IDownloadService usenet = CreateClient(DownloadClientType.Usenet); + IMockDownloadService usenet = CreateClient(DownloadClientType.Usenet); // Act IReadOnlyList decisions = @@ -75,12 +76,36 @@ public class LazyLibrarianJobEvaluatorTests await usenet.DidNotReceive().ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()); } + [Fact] + public async Task EvaluateAsync_QueriesOnlyTheClientMatchingTheItemsProtocol() + { + // Arrange: a usenet snatch must never reach a torrent client's capability, and vice versa. + LazyLibrarianQueueItem usenetItem = CreateItem("HASH1") with { Source = LazyLibrarianSource.Sabnzbd }; + LazyLibrarianQueueItem torrentItem = CreateItem("HASH2"); + StubQueue(usenetItem, torrentItem); + + IMockDownloadService torrentClient = CreateClient(DownloadClientType.Torrent); + IMockDownloadService usenetClient = CreateClient(DownloadClientType.Usenet); + + torrentClient.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) + .Returns(new DownloadCheckResult { Found = false }); + usenetClient.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) + .Returns(new DownloadCheckResult { Found = false }); + + // Act + await CreateQueueCleanerEvaluator().EvaluateAsync(_instance, [torrentClient, usenetClient], []); + + // Assert + await torrentClient.Received(1).ShouldRemoveFromArrQueueAsync("HASH2", Arg.Any>()); + await usenetClient.Received(1).ShouldRemoveFromArrQueueAsync("HASH1", Arg.Any>()); + } + [Fact] public async Task EvaluateAsync_SkipsAnIgnoredDownload() { // Arrange StubQueue(CreateItem()); - IDownloadService client = CreateClient(); + IMockDownloadService client = CreateClient(); // Act IReadOnlyList decisions = @@ -96,7 +121,7 @@ public class LazyLibrarianJobEvaluatorTests { // Arrange StubQueue(CreateItem()); - IDownloadService client = CreateClient(); + IMockDownloadService client = CreateClient(); client.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = false }); @@ -113,7 +138,7 @@ public class LazyLibrarianJobEvaluatorTests { // Arrange StubQueue(CreateItem()); - IDownloadService client = CreateClient(); + IMockDownloadService client = CreateClient(); client.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = false }); @@ -132,18 +157,18 @@ public class LazyLibrarianJobEvaluatorTests StubQueue(CreateItem()); ITorrentItemWrapper torrent = Substitute.For(); - IDownloadService broken = CreateClient(); + IMockDownloadService broken = CreateClient(); broken.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Throws(new Exception("client is down")); - IDownloadService healthy = CreateClient(); + IMockDownloadService healthy = CreateClient(); healthy.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = true, DeleteReason = DeleteReason.Stalled, - Torrent = torrent, + Item = torrent, }); // Act @@ -154,7 +179,7 @@ public class LazyLibrarianJobEvaluatorTests LazyLibrarianRemovalDecision decision = decisions.ShouldHaveSingleItem(); decision.DownloadService.ShouldBe(healthy); decision.DownloadClient.ShouldBe(healthy.ClientConfig); - decision.Torrent.ShouldBe(torrent); + decision.Download.ShouldBe(torrent); decision.DeleteReason.ShouldBe(DeleteReason.Stalled); } @@ -170,7 +195,7 @@ public class LazyLibrarianJobEvaluatorTests { // Arrange StubQueue(CreateItem()); - IDownloadService client = CreateClient(); + IMockDownloadService client = CreateClient(); client.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { @@ -178,7 +203,7 @@ public class LazyLibrarianJobEvaluatorTests ShouldRemove = true, IsPrivate = isPrivate, DeleteFromClient = deleteFromClient, - Torrent = Substitute.For(), + Item = Substitute.For(), }); // Act @@ -203,7 +228,7 @@ public class LazyLibrarianJobEvaluatorTests ContextProvider.Set(nameof(ContentBlockerConfig), new ContentBlockerConfig { DeletePrivate = deletePrivate }); StubQueue(CreateItem()); - IDownloadService client = CreateClient(); + IMockDownloadService client = CreateClient(); client.BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) .Returns(new BlockFilesResult { @@ -211,7 +236,7 @@ public class LazyLibrarianJobEvaluatorTests ShouldRemove = true, IsPrivate = isPrivate, DeleteReason = DeleteReason.AllFilesBlocked, - Torrent = Substitute.For(), + Item = Substitute.For(), }); // Act @@ -229,13 +254,13 @@ public class LazyLibrarianJobEvaluatorTests { // Arrange StubQueue(CreateItem("HASH1"), CreateItem("HASH2")); - IDownloadService client = CreateClient(); + IMockDownloadService client = CreateClient(); client.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) .Returns(new DownloadCheckResult { Found = true, ShouldRemove = true, - Torrent = Substitute.For(), + Item = Substitute.For(), }); // Act diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs index da1d9328..0f958605 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs @@ -162,7 +162,46 @@ public class LazyLibrarianServiceTests IReadOnlyList items = await _service.GetQueueAsync(_instance); // Assert - items.ShouldHaveSingleItem().Source.IsTorrentClient().ShouldBeTrue(); + items.ShouldHaveSingleItem().Source.ClientType().ShouldBe(DownloadClientType.Torrent); + } + + [Fact] + public async Task GetQueueAsync_AcceptsAnNzbSnatchToSabnzbd() + { + // Arrange + RespondWith($"[{Row(mode: "nzb", source: "SABNZBD")}]"); + + // Act + IReadOnlyList items = await _service.GetQueueAsync(_instance); + + // Assert + items.ShouldHaveSingleItem().Source.ShouldBe(LazyLibrarianSource.Sabnzbd); + } + + [Fact] + public async Task GetQueueAsync_NeverTreatsASabnzbdSnatchAsAdopted() + { + // Arrange: LazyLibrarian writes no origin for an NZB snatch. + RespondWith($"[{Row(mode: "nzb", source: "SABNZBD", origin: "")}]"); + + // Act + IReadOnlyList items = await _service.GetQueueAsync(_instance); + + // Assert + items.ShouldHaveSingleItem().WasAdoptedByLazyLibrarian.ShouldBeFalse(); + } + + [Fact] + public async Task GetQueueAsync_RejectsAnNzbSnatchToNzbGet() + { + // Arrange: Cleanuparr has no NzbGet client, so the row can never be queried. + RespondWith($"[{Row(mode: "nzb", source: "NZBGET")}]"); + + // Act + IReadOnlyList items = await _service.GetQueueAsync(_instance); + + // Assert + items.ShouldBeEmpty(); } [Theory] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs index adc79178..08a5a22e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs @@ -84,7 +84,7 @@ public class QueueRuleEvaluatorTests : IDisposable long downloadedBytes = 0; var torrent = Substitute.For(); - torrent.Hash.Returns("hash"); + torrent.DownloadId.Returns("hash"); torrent.Name.Returns("Example Torrent"); torrent.IsPrivate.Returns(false); torrent.Size.Returns(ByteSize.Parse("100 MB").Bytes); @@ -963,7 +963,7 @@ public class QueueRuleEvaluatorTests : IDisposable string size = "100 MB") { var torrent = Substitute.For(); - torrent.Hash.Returns(hash); + torrent.DownloadId.Returns(hash); torrent.Name.Returns(name); torrent.IsPrivate.Returns(isPrivate); torrent.CompletionPercentage.Returns(completionPercentage); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleManagerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleManagerTests.cs index 55e774c0..1478a1ae 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleManagerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleManagerTests.cs @@ -373,7 +373,7 @@ public class QueueRuleManagerTests string size = "100 MB") { var torrent = Substitute.For(); - torrent.Hash.Returns("test-hash"); + torrent.DownloadId.Returns("test-hash"); torrent.Name.Returns("Test Torrent"); torrent.IsPrivate.Returns(isPrivate); torrent.CompletionPercentage.Returns(completionPercentage); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs index fb3b97cf..a3063045 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/DeadTorrentService.cs @@ -32,7 +32,7 @@ public sealed class DeadTorrentService : IDeadTorrentService _striker = striker; } - public async Task ProcessAsync(IDownloadService downloadService, List clientDownloads) + public async Task ProcessAsync(IDeadTorrentCapable downloadService, List clientDownloads) { DeadTorrentConfig? config = await _dataContext.DeadTorrentConfigs .AsNoTracking() @@ -50,7 +50,7 @@ public sealed class DeadTorrentService : IDeadTorrentService } List candidates = clientDownloads - .Where(t => !string.IsNullOrEmpty(t.Hash)) + .Where(t => !string.IsNullOrEmpty(t.DownloadId)) .Where(t => config.Categories.Any(cat => cat.Equals(t.Category, StringComparison.OrdinalIgnoreCase))) .Where(t => config.UseTag ? !t.Tags.Contains(config.TargetCategory, StringComparer.OrdinalIgnoreCase) @@ -81,14 +81,14 @@ public sealed class DeadTorrentService : IDeadTorrentService { ContextProvider.SetDownloadClient(downloadService.ClientConfig); ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); + ContextProvider.Set(ContextProvider.Keys.Hash, torrent.DownloadId); bool unregistered = torrent.TrackerHealth is TrackerHealth.Unregistered && !WithinGracePeriod(torrent); if (torrent.SeederCount > 0 && !unregistered) { - await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, StrikeType.DeadTorrent); + await _striker.ResetStrikeAsync(torrent.DownloadId, torrent.Name, StrikeType.DeadTorrent); continue; } @@ -102,7 +102,7 @@ public sealed class DeadTorrentService : IDeadTorrentService torrent.Name); bool shouldMove = await _striker.StrikeAndCheckLimit( - torrent.Hash, + torrent.DownloadId, torrent.Name, config.MaxStrikes, StrikeType.DeadTorrent); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs index d474b6fd..d66ed014 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IDeadTorrentService.cs @@ -15,5 +15,5 @@ public interface IDeadTorrentService /// /// Download-client service for the current client. /// The client's torrent items to evaluate. - Task ProcessAsync(IDownloadService downloadService, List clientDownloads); + Task ProcessAsync(IDeadTorrentCapable downloadService, List clientDownloads); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs index 4e47efd0..181ac4e9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/ISeedingRulesCleanupService.cs @@ -11,5 +11,5 @@ public interface ISeedingRulesCleanupService /// /// Evaluates the seeding rules against the client's downloads and removes those that match. /// - Task CleanAsync(IDownloadService downloadService, List clientDownloads); + Task CleanAsync(ISeedingCleanupCapable downloadService, List clientDownloads); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs index 811b4862..2571730e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/IUnlinkedDownloadsService.cs @@ -13,5 +13,5 @@ public interface IUnlinkedDownloadsService /// Re-categorises downloads with no hard links according to the supplied /// configuration. /// - Task ProcessAsync(IDownloadService downloadService, List clientDownloads); + Task ProcessAsync(IUnlinkedCapable downloadService, List clientDownloads); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs index 8602a075..c04b4d03 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/OrphanedFilesCleanupService.cs @@ -86,7 +86,13 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService continue; } - bool success = await TryAddClaimedPathsAsync(downloadService, claimedPaths); + if (downloadService is not IOrphanClaimsCapable orphanClaimsCapableService) + { + skippedClientIds.Add(downloadService.ClientConfig.Id); + continue; + } + + bool success = await TryAddClaimedPathsAsync(orphanClaimsCapableService, claimedPaths); if (!success) { skippedClientIds.Add(downloadService.ClientConfig.Id); @@ -134,13 +140,23 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService } } - private async Task TryAddClaimedPathsAsync(IDownloadService downloadService, HashSet claimedPaths) + private async Task TryAddClaimedPathsAsync(IOrphanClaimsCapable downloadService, HashSet claimedPaths) { DownloadClientConfig downloadClient = downloadService.ClientConfig; - List torrents; + IReadOnlyList clientClaimedPaths; + int torrentCount; + try { - torrents = await downloadService.GetAllTorrentsLite(); + List torrents = await downloadService.GetAllDownloadsLite(); + torrentCount = torrents.Count; + + if (torrentCount is 0) + { + _logger.LogInformation("No torrents found in the download client | {Name}", downloadClient.Name); + } + + clientClaimedPaths = await downloadService.GetClaimedPathsAsync(torrents); } catch (Exception ex) { @@ -148,17 +164,12 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService return false; } - if (torrents.Count is 0) - { - _logger.LogInformation("No torrents found in the download client | {Name}", downloadClient.Name); - } - - foreach (string claimedPath in await downloadService.GetClaimedPathsAsync(torrents)) + foreach (string claimedPath in clientClaimedPaths) { claimedPaths.Add(claimedPath); } - _logger.LogDebug("Loaded {Count} torrents | {Name}", torrents.Count, downloadClient.Name); + _logger.LogDebug("Loaded {Count} torrents | {Name}", torrentCount, downloadClient.Name); return true; } @@ -196,6 +207,12 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService continue; } + if (HoldsClaimedPath(normalizedPath, claimedPaths)) + { + _logger.LogDebug("skip | holds claimed path | {Path}", normalizedPath); + continue; + } + string entryName = Path.GetFileName(normalizedPath); if (clientConfig.ExcludePatterns.Any(pattern => FileSystemName.MatchesSimpleExpression(pattern, entryName, ignoreCase: true))) { @@ -236,6 +253,15 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService } } + /// + /// Whether any claimed path lives inside the entry, which makes the entry an ancestor to keep. + /// + private static bool HoldsClaimedPath(string entry, HashSet claimedPaths) + { + string prefix = entry + Path.DirectorySeparatorChar; + return claimedPaths.Any(claimedPath => claimedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + private void MoveToOrphanedDirectory(string path, string orphanedDirectory) { string entryName = Path.GetFileName(path); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs index c310219e..0771589f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/SeedingRulesCleanupService.cs @@ -21,7 +21,7 @@ public sealed class SeedingRulesCleanupService : ISeedingRulesCleanupService _dataContext = dataContext; } - public async Task CleanAsync(IDownloadService downloadService, List clientDownloads) + public async Task CleanAsync(ISeedingCleanupCapable downloadService, List clientDownloads) { try { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs index e77f1ee2..b5889145 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadCleaner/Services/UnlinkedDownloadsService.cs @@ -25,7 +25,7 @@ public sealed class UnlinkedDownloadsService : IUnlinkedDownloadsService _hardLinkFileService = hardLinkFileService; } - public async Task ProcessAsync(IDownloadService downloadService, List clientDownloads) + public async Task ProcessAsync(IUnlinkedCapable downloadService, List clientDownloads) { UnlinkedConfig? unlinkedConfig = await _dataContext.UnlinkedConfigs .AsNoTracking() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs index fc7f7f87..8c7a48b3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs @@ -20,7 +20,7 @@ public sealed record BlockFilesResult public DeleteReason DeleteReason { get; set; } = DeleteReason.None; /// - /// The matched torrent, set when is true. + /// The matched download item, set when is true. /// - public ITorrentItemWrapper? Torrent { get; set; } + public IDownloadItem? Item { get; set; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs index 76e0c2e6..b143c1e2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeItemWrapper.cs @@ -25,7 +25,7 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper .AsReadOnly()); } - public string Hash => Info.Hash ?? string.Empty; + public string DownloadId => Info.Hash ?? string.Empty; public string Name => Info.Name ?? string.Empty; @@ -98,7 +98,7 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper foreach (string pattern in ignoredDownloads) { - if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true) + if (DownloadId?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true) { return true; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs index 1d4f4b4c..ead18a8d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs @@ -16,7 +16,7 @@ using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; -public partial class DelugeService : DownloadService +public partial class DelugeService : TorrentDownloadService, IDeadTorrentCapable { private readonly IDelugeClientWrapper _client; @@ -146,8 +146,4 @@ public partial class DelugeService : DownloadService } } } - - public override void Dispose() - { - } -} \ No newline at end of file +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs index 23c7c6f0..9b1d4824 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs @@ -10,22 +10,22 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; public partial class DelugeService { /// - public override async Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads) { - hash = hash.ToLowerInvariant(); + downloadId = downloadId.ToLowerInvariant(); - DownloadStatus? download = await _client.GetTorrentStatus(hash); + DownloadStatus? download = await _client.GetTorrentStatus(downloadId); BlockFilesResult result = new(); - + if (download?.Hash is null || download?.Name is null) { - _logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } - + result.IsPrivate = download.Private; result.Found = true; - result.Torrent = new DelugeItemWrapper(download); + result.Item = new DelugeItemWrapper(download); SetDownloadClientContext(); if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) @@ -47,7 +47,7 @@ public partial class DelugeService try { - contents = await _client.GetTorrentFiles(hash); + contents = await _client.GetTorrentFiles(downloadId); } catch (Exception exception) { @@ -79,7 +79,7 @@ public partial class DelugeService .Select(x => unwantedLookup.Contains(x.Key) ? 0 : x.Value) .ToList(); - await _client.ChangeFilesPriority(hash, sortedPriorities); + await _client.ChangeFilesPriority(downloadId, sortedPriorities); }); return result; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs index 5201e7c9..155070d8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs @@ -26,7 +26,7 @@ public partial class DelugeService } /// - public override async Task> GetAllTorrentsLite() + public override async Task> GetAllDownloadsLite() { List? downloads = await _client.GetStatusForAllTorrents(); if (downloads is null) @@ -34,9 +34,9 @@ public partial class DelugeService throw new DelugeClientException("Deluge returned no torrent status"); } - List torrents = downloads + List torrents = downloads .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Select(ITorrentItemWrapper (x) => new DelugeItemWrapper(x)) + .Select(IDownloadItem (x) => new DelugeItemWrapper(x)) .ToList(); ThrowIfTorrentListCollapsed(downloads.Count, torrents.Count); @@ -45,15 +45,15 @@ public partial class DelugeService } /// - public override Task> GetClaimedPathsAsync(IReadOnlyList torrents) => - BuildClaimedPathsAsync(torrents, async torrent => + public override Task> GetClaimedPathsAsync(IReadOnlyList downloads) => + BuildClaimedPathsAsync(downloads, async torrent => { - if (string.IsNullOrEmpty(torrent.Hash)) + if (string.IsNullOrEmpty(torrent.DownloadId)) { return []; } - DelugeContents? contents = await _client.GetTorrentFiles(torrent.Hash); + DelugeContents? contents = await _client.GetTorrentFiles(torrent.DownloadId); List relativePaths = []; ProcessFiles(contents?.Contents, (_, file) => { @@ -66,16 +66,16 @@ public partial class DelugeService }); /// - public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) + public override async Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) { - string hash = torrent.Hash.ToLowerInvariant(); + string hash = item.DownloadId.ToLowerInvariant(); await _client.DeleteTorrents([hash], deleteSourceFiles); } - public override async Task StopDownload(ITorrentItemWrapper torrent) + public override async Task StopDownload(IDownloadItem item) { - string hash = torrent.Hash.ToLowerInvariant(); + string hash = item.DownloadId.ToLowerInvariant(); await _client.PauseTorrents([hash]); } @@ -102,7 +102,7 @@ public partial class DelugeService try { - contents = await _client.GetTorrentFiles(delugeTorrent.Hash); + contents = await _client.GetTorrentFiles(delugeTorrent.DownloadId); } catch (Exception exception) { @@ -131,7 +131,7 @@ public partial class DelugeService /// protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => - ChangeLabel(torrent.Hash, targetCategory); + ChangeLabel(torrent.DownloadId, targetCategory); protected async Task CreateLabel(string name) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs index b3432859..e7be2c22 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs @@ -9,28 +9,28 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; public partial class DelugeService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads) { - hash = hash.ToLowerInvariant(); - + downloadId = downloadId.ToLowerInvariant(); + DelugeContents? contents = null; DownloadCheckResult result = new(); - DownloadStatus? download = await _client.GetTorrentStatus(hash); - + DownloadStatus? download = await _client.GetTorrentStatus(downloadId); + if (download?.Hash is null) { - _logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } - + result.IsPrivate = download.Private; result.Found = true; SetDownloadClientContext(); // Create ITorrentItem wrapper for consistent interface usage DelugeItemWrapper torrent = new(download); - result.Torrent = torrent; + result.Item = torrent; if (torrent.IsIgnored(ignoredDownloads)) { @@ -40,7 +40,7 @@ public partial class DelugeService try { - contents = await _client.GetTorrentFiles(hash); + contents = await _client.GetTorrentFiles(downloadId); } catch (Exception exception) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs index b7485b98..eda1964e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs @@ -30,7 +30,7 @@ public sealed record DownloadCheckResult public bool ChangeCategory { get; set; } /// - /// The matched torrent, set when is true. + /// The matched download item, set when is true. /// - public ITorrentItemWrapper? Torrent { get; set; } + public IDownloadItem? Item { get; set; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index 34e69467..fcec086e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -6,62 +6,51 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Files; -using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.MalwareBlocker; using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Interceptors; -using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence.Models.Configuration; -using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadClient; -public abstract class DownloadService : IDownloadService +/// +/// Base download client implementation, shared by every protocol. +/// Torrent-only behaviour (seeding, hardlinks, slow/stall rules) lives in . +/// +public abstract class DownloadService : IQueueCheckCapable, IFileBlockingCapable, IOrphanClaimsCapable { protected readonly ILogger _logger; protected readonly IFilenameEvaluator _filenameEvaluator; - protected readonly IStriker _striker; protected readonly IDryRunInterceptor _dryRunInterceptor; - protected readonly IHardLinkFileService _hardLinkFileService; protected readonly IEventPublisher _eventPublisher; protected readonly IBlocklistProvider _blocklistProvider; protected readonly HttpClient _httpClient; protected readonly DownloadClientConfig _downloadClientConfig; - protected readonly IQueueRuleEvaluator _queueRuleEvaluator; - private readonly ISeedingRuleEvaluator _seedingRuleEvaluator; protected readonly TimeProvider _timeProvider; protected DownloadService( ILogger logger, IFilenameEvaluator filenameEvaluator, - IStriker striker, IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, - IQueueRuleEvaluator queueRuleEvaluator, - ISeedingRuleEvaluator seedingRuleEvaluator, TimeProvider timeProvider ) { _logger = logger; _filenameEvaluator = filenameEvaluator; - _striker = striker; _dryRunInterceptor = dryRunInterceptor; - _hardLinkFileService = hardLinkFileService; _eventPublisher = eventPublisher; _blocklistProvider = blocklistProvider; _downloadClientConfig = downloadClientConfig; _httpClient = httpClientProvider.CreateClient(downloadClientConfig); - _queueRuleEvaluator = queueRuleEvaluator; - _seedingRuleEvaluator = seedingRuleEvaluator; _timeProvider = timeProvider; } - + public DownloadClientConfig ClientConfig => _downloadClientConfig; protected void SetDownloadClientContext() @@ -69,76 +58,28 @@ public abstract class DownloadService : IDownloadService ContextProvider.SetDownloadClient(_downloadClientConfig); } - protected virtual Task IsAltSpeedLimitActiveAsync() + public void Dispose() { - return Task.FromResult(false); + Dispose(true); + GC.SuppressFinalize(this); } - /// - /// Runs the slow check, then the stall check; the first removal wins. - /// - protected async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + protected virtual void Dispose(bool disposing) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(wrapper); } - /// - /// Alt-speed-limit probe for the slow rules. - /// Only qBittorrent and Transmission report that state. - /// - protected virtual Func>? GetAltSpeedLimitProbe() => null; - - protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsDownloading()) - { - _logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - if (wrapper.DownloadSpeed <= 0) - { - _logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, GetAltSpeedLimitProbe()); - } - - protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsStalled()) - { - _logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); - } - - public abstract void Dispose(); - public abstract Task LoginAsync(); public abstract Task HealthCheckAsync(); - public abstract Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads); + /// + public abstract Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads); - /// - public abstract Task> GetSeedingDownloads(); + /// + public abstract Task> GetAllDownloadsLite(); - /// - public abstract Task> GetAllTorrentsLite(); - - /// - public abstract Task> GetClaimedPathsAsync(IReadOnlyList torrents); + /// + public abstract Task> GetClaimedPathsAsync(IReadOnlyList downloads); /// /// Rejects a torrent list that lost any row the client reported. @@ -157,12 +98,12 @@ public abstract class DownloadService : IDownloadService } protected async Task> BuildClaimedPathsAsync( - IReadOnlyList torrents, - Func>> resolveRelativeFilePaths) + IReadOnlyList downloads, + Func>> resolveRelativeFilePaths) { HashSet claimed = new(StringComparer.OrdinalIgnoreCase); - foreach (ITorrentItemWrapper torrent in torrents) + foreach (IDownloadItem torrent in downloads) { IReadOnlyCollection relativeFilePaths; try @@ -185,9 +126,9 @@ public abstract class DownloadService : IDownloadService } /// - /// The top-level entries a torrent occupies. + /// The top-level entries a download occupies. /// - private IReadOnlyList BuildClaimedPaths(ITorrentItemWrapper torrent, IReadOnlyCollection relativeFilePaths) + private IReadOnlyList BuildClaimedPaths(IDownloadItem torrent, IReadOnlyCollection relativeFilePaths) { List claimed = []; if (string.IsNullOrEmpty(torrent.SavePath)) @@ -226,198 +167,6 @@ public abstract class DownloadService : IDownloadService .NormalizeAndRemap(path, _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget) .TrimEnd(Path.DirectorySeparatorChar); - /// - public virtual List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => - downloads - ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) - .ToList(); - - /// - public virtual List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) => - downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) - .ToList(); - - /// - public virtual async Task CleanDownloadsAsync(List? downloads, List seedingRules) - { - if (downloads?.Count is null or 0) - { - return; - } - - foreach (ITorrentItemWrapper torrent in downloads) - { - if (string.IsNullOrEmpty(torrent.Hash)) - { - continue; - } - - ISeedingRule? seedingRule = _seedingRuleEvaluator.GetMatchingRule(torrent, seedingRules); - - if (seedingRule is null) - { - _logger.LogTrace("No seeding rules matched | {Name}", torrent.Name); - continue; - } - - _logger.LogTrace("Seeding rule matched | {SeedingRule} | {Name}", seedingRule.Name, torrent.Name); - - if (seedingRule.Action is SeedingRuleAction.Unknown) - { - _logger.LogWarning( - "Skipping seeding rule with an action this version does not know | {SeedingRule} | {Name}", - seedingRule.Name, - torrent.Name - ); - continue; - } - - if (seedingRule.Action is SeedingRuleAction.Stop && torrent.IsStopped) - { - continue; - } - - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds); - SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, torrent.SeederCount, torrent.LastActivityTime, seedingRule); - - if (!result.ShouldClean) - { - continue; - } - - bool stopping = seedingRule.Action is SeedingRuleAction.Stop; - - try - { - await _dryRunInterceptor.InterceptAsync(() => stopping - ? StopDownload(torrent) - : DeleteDownload(torrent, seedingRule.DeleteSourceFiles)); - } - catch (Exception exception) - { - // The download stays in the client. The run continues with the next download. - _logger.LogError(exception, "failed to clean download | {Name}", torrent.Name); - continue; - } - - string reason = result.Reason is CleanReason.MaxRatioReached - ? "MAX_RATIO & MIN_SEED_TIME" - : "MAX_SEED_TIME"; - - if (stopping) - { - _logger.LogInformation("download stopped | {Reason} reached | {Name}", reason, torrent.Name); - - await _eventPublisher.PublishDownloadStopped(torrent.Ratio, seedingTime, torrent.Category ?? string.Empty, result.Reason); - continue; - } - - _logger.LogInformation( - "download cleaned | {Reason} reached | delete files: {DeleteFiles} | {Name}", - reason, - seedingRule.DeleteSourceFiles, - torrent.Name - ); - - await _eventPublisher.PublishDownloadCleaned(torrent.Ratio, seedingTime, torrent.Category ?? string.Empty, result.Reason); - } - } - - /// - public async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) - { - if (downloads?.Count is null or 0) - { - return; - } - - foreach (ITorrentItemWrapper torrent in downloads) - { - if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category)) - { - continue; - } - - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - IEnumerable<(string FilePath, HardLinkScanAction Action)>? files = await GetHardLinkScanItemsAsync(torrent); - - if (files is null) - { - continue; - } - - (bool hasHardlinks, bool hasErrors) = ScanForHardLinks(files, unlinkedConfig.IgnoredRootDirs.Count > 0); - - if (hasErrors) - { - continue; - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name); - continue; - } - - await ChangeTorrentCategoryAsync(torrent, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag); - - _logger.LogInformation(unlinkedConfig.UseTag && SupportsTags ? "tag added for {Name}" : "category changed for {Name}", torrent.Name); - } - } - - /// - /// Maps a torrent's files to (path, ) pairs. - /// Returns null to skip the torrent, such as when its files can't be read. - /// - protected abstract Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent); - - /// - /// Stops at the first hardlink or unreadable file. - /// Each client maps its files to (path, ) pairs. - /// - protected (bool HasHardlinks, bool HasErrors) ScanForHardLinks( - IEnumerable<(string FilePath, HardLinkScanAction Action)> files, - bool ignoreRootDirs) - { - foreach ((string filePath, HardLinkScanAction action) in files) - { - if (action is HardLinkScanAction.TreatAsLinked) - { - return (true, false); - } - - if (action is HardLinkScanAction.SkipUnwanted) - { - _logger.LogDebug("skip | file is not downloaded | {File}", filePath); - continue; - } - - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, ignoreRootDirs); - - if (hardlinkCount < 0) - { - _logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath); - return (false, true); - } - - if (hardlinkCount > 0) - { - return (true, false); - } - } - - return (false, false); - } - /// /// Counts unwanted files and collects the indices to block. /// Don't yield a file the client excludes from the count, such as one with no index. @@ -525,199 +274,23 @@ public abstract class DownloadService : IDownloadService } } - /// - public async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) - { - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - string currentCategory = torrent.Category ?? string.Empty; - useTag = useTag && SupportsTags; - - await _dryRunInterceptor.InterceptAsync(() => ChangeCategoryInClientAsync(torrent, targetCategory, useTag)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag); - - if (!useTag) - { - torrent.Category = targetCategory; - } - } - - /// - /// Clients without tags fall back to a category change when asked for a tag. - /// - protected virtual bool SupportsTags => false; - - /// - /// Applies the category, or the tag when is set, in the client. - /// - protected abstract Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); - - /// - public abstract Task CreateCategoryAsync(string name); - - /// - public abstract Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads); + /// + public abstract Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads); /// /// Deletes the specified download from the download client. /// Each client implementation handles the deletion according to its API requirements. /// - /// The torrent to delete - /// Whether to delete the source files along with the torrent - public abstract Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles); + /// The download item to delete + /// Whether to delete the source files along with the download + public abstract Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles); - /// - /// Stops the specified download in the download client, leaving it there. - /// Each client implementation handles the stop according to its API requirements. - /// - /// The torrent to stop - public abstract Task StopDownload(ITorrentItemWrapper torrent); - - private SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, int? seederCount, DateTime? lastActivity, ISeedingRule seedingRule) - { - if (BelowMinimumSeeders(seederCount, seedingRule)) - { - return new(); - } - - if (StillActiveWithinWindow(lastActivity, seedingRule)) - { - return new(); - } - - // check ratio - if (DownloadReachedRatio(ratio, seedingTime, seedingRule)) - { - return new() - { - ShouldClean = true, - Reason = CleanReason.MaxRatioReached - }; - } - - // check max seed time - if (DownloadReachedMaxSeedTime(seedingTime, seedingRule)) - { - return new() - { - ShouldClean = true, - Reason = CleanReason.MaxSeedTimeReached - }; - } - - return new(); - } - - private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, ISeedingRule seedingRule) - { - if (seedingRule.MaxRatio < 0) - { - return false; - } - - string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); - TimeSpan minSeedingTime = TimeSpan.FromHours(seedingRule.MinSeedTime); - - if (seedingRule.MinSeedTime > 0 && seedingTime < minSeedingTime) - { - _logger.LogDebug("skip | download has not reached MIN_SEED_TIME | {name}", downloadName); - return false; - } - - if (ratio < seedingRule.MaxRatio) - { - _logger.LogDebug("skip | download has not reached MAX_RATIO | {name}", downloadName); - return false; - } - - // max ratio is 0 or reached - return true; - } - - private bool BelowMinimumSeeders(int? seederCount, ISeedingRule seedingRule) - { - if (seedingRule is not ISeedersFilterable { MinSeeders: > 0 } seedersFilterable) - { - return false; - } - - string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); - - if (!seederCount.HasValue) - { - _logger.LogDebug("skip | download seeder count is unavailable | {name}", downloadName); - return true; - } - - if (seederCount.Value >= seedersFilterable.MinSeeders) - { - return false; - } - - _logger.LogDebug( - "skip | download has fewer seeders than minimum | {seeders}/{minSeeders} | {name}", - seederCount.Value, - seedersFilterable.MinSeeders, - downloadName); - return true; - - } - - private bool StillActiveWithinWindow(DateTime? lastActivity, ISeedingRule seedingRule) - { - if (seedingRule is not IInactivityFilterable { MaxInactiveDays: >= 0 } inactivityRule) - { - return false; - } - - string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); - - if (lastActivity is null || lastActivity <= DateTime.UnixEpoch) - { - _logger.LogDebug("skip | download last activity is unavailable | {name}", downloadName); - return true; - } - - double inactiveDays = (_timeProvider.GetUtcNow().UtcDateTime - lastActivity.Value).TotalDays; - - if (inactiveDays < inactivityRule.MaxInactiveDays) - { - _logger.LogDebug("skip | download still active within MAX_INACTIVE_DAYS | {name}", downloadName); - return true; - } - - return false; - } - - private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, ISeedingRule seedingRule) - { - if (seedingRule.MaxSeedTime < 0) - { - return false; - } - - string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); - TimeSpan maxSeedingTime = TimeSpan.FromHours(seedingRule.MaxSeedTime); - - if (seedingRule.MaxSeedTime > 0 && seedingTime < maxSeedingTime) - { - _logger.LogDebug("skip | download has not reached MAX_SEED_TIME | {name}", downloadName); - return false; - } - - // max seed time is 0 or reached - return true; - } - protected bool TryDeleteFiles(string path, bool failOnNotFound) { if (string.IsNullOrEmpty(path)) { _logger.LogTrace("File path is null or empty"); - + if (failOnNotFound) { return false; @@ -753,7 +326,7 @@ public abstract class DownloadService : IDownloadService return false; } } - + _logger.LogTrace("File path to delete not found: {path}", path); if (failOnNotFound) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs index 60f75300..61b7cd00 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService; using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService; using RTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent.RTorrentService; +using SabnzbdService = Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd.SabnzbdService; using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService; using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService; @@ -46,6 +47,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory DownloadClientTypeName.Transmission => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), DownloadClientTypeName.uTorrent => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), DownloadClientTypeName.rTorrent => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), + DownloadClientTypeName.Sabnzbd => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), _ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported") }; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDeadTorrentCapable.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDeadTorrentCapable.cs new file mode 100644 index 00000000..598d4b07 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDeadTorrentCapable.cs @@ -0,0 +1,9 @@ +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Torrent clients that report a seeder count, so dead-torrent detection applies. +/// rTorrent does not. +/// +public interface IDeadTorrentCapable : ISeedingCleanupCapable, IUnlinkedCapable +{ +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs index d024530b..05d567ea 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IDownloadService.cs @@ -1,110 +1,29 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.HealthCheck; using Cleanuparr.Persistence.Models.Configuration; -using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; namespace Cleanuparr.Infrastructure.Features.DownloadClient; +/// +/// Base contract every download client implements, regardless of protocol or capabilities. +/// Protocol-specific behaviour lives behind the I*Capable interfaces. +/// public interface IDownloadService : IDisposable { DownloadClientConfig ClientConfig { get; } - public Task LoginAsync(); + Task LoginAsync(); /// /// Performs a health check on the download client /// /// The health check result - public Task HealthCheckAsync(); - - /// - /// Checks whether the download should be removed from the *arr queue. - /// - /// The download hash. - /// Downloads to ignore from processing. - public Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads); - - /// - /// Fetches all seeding downloads. - /// - /// A list of downloads that are seeding. - Task> GetSeedingDownloads(); - - /// - /// Fetches all torrents regardless of their state, without per-torrent tracker or properties calls. - /// Used by the orphaned files cleanup to identify which paths are claimed by active torrents. - /// - /// A list of all torrents. - Task> GetAllTorrentsLite(); - - /// - /// Resolves the on-disk paths claimed by the given torrents. - /// - /// The distinct, remapped paths claimed by the torrents. - Task> GetClaimedPathsAsync(IReadOnlyList torrents); - - /// - /// Filters downloads that should be cleaned. - /// - /// The downloads to filter. - /// The seeding rules by which to filter the downloads. - /// A list of downloads for the provided categories. - List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules); - - /// - /// Filters downloads that should have their category changed. - /// - /// The downloads to filter. - /// The unlinked config for this download client. - /// A list of downloads for the provided categories. - List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig); - - /// - /// Cleans the downloads. - /// - /// The downloads to clean. - /// The seeding rules. - Task CleanDownloadsAsync(List? downloads, List seedingRules); - - /// - /// Changes the category for downloads that have no hardlinks. - /// - /// The downloads to change. - /// The unlinked config for this download client. - Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig); - - /// - /// Moves a single torrent to the target category, or adds it as a tag/label when is set. - /// - /// The torrent to move. - /// The target category/tag. - /// When true, add a tag/label instead of changing the category (qBittorrent and Transmission). - Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); + Task HealthCheckAsync(); /// /// Deletes a download item. /// - /// The torrent item. - /// Whether to delete the source files along with the torrent. Defaults to true. - public Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles); - - /// - /// Stops a download, leaving it in the client. - /// - /// The torrent item. - public Task StopDownload(ITorrentItemWrapper torrent); - - /// - /// Creates a category. - /// - /// The category name. - public Task CreateCategoryAsync(string name); - - /// - /// Blocks unwanted files from being fully downloaded. - /// - /// The torrent hash. - /// Downloads to ignore from processing. - /// True if all files have been blocked; otherwise false. - public Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads); -} \ No newline at end of file + /// The download item. + /// Whether to delete the source files along with the download. Defaults to true. + Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IFileBlockingCapable.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IFileBlockingCapable.cs new file mode 100644 index 00000000..501988bd --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IFileBlockingCapable.cs @@ -0,0 +1,16 @@ +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Download clients that can block unwanted files from completing. +/// Implemented by every current client (torrent and usenet). +/// +public interface IFileBlockingCapable : IDownloadService +{ + /// + /// Blocks unwanted files from being fully downloaded. + /// + /// The download id. + /// Downloads to ignore from processing. + /// True if all files have been blocked; otherwise false. + Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IOrphanClaimsCapable.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IOrphanClaimsCapable.cs new file mode 100644 index 00000000..523bc8b5 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IOrphanClaimsCapable.cs @@ -0,0 +1,23 @@ +using Cleanuparr.Domain.Entities; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Download clients that can report the on-disk paths their downloads claim, for orphaned-files cleanup. +/// Implemented by every current client (torrent and usenet). +/// +public interface IOrphanClaimsCapable : IDownloadService +{ + /// + /// Fetches all downloads regardless of their state, without per-item tracker or properties calls. + /// Used by the orphaned files cleanup to identify which paths are claimed by active downloads. + /// + /// A list of all downloads. + Task> GetAllDownloadsLite(); + + /// + /// Resolves the on-disk paths claimed by the given downloads. + /// + /// The distinct, remapped paths claimed by the downloads. + Task> GetClaimedPathsAsync(IReadOnlyList downloads); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IQueueCheckCapable.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IQueueCheckCapable.cs new file mode 100644 index 00000000..a2c6216f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IQueueCheckCapable.cs @@ -0,0 +1,15 @@ +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Download clients that can report whether an *arr queue item should be removed. +/// Implemented by every current client (torrent and usenet). +/// +public interface IQueueCheckCapable : IDownloadService +{ + /// + /// Checks whether the download should be removed from the *arr queue. + /// + /// The download id. + /// Downloads to ignore from processing. + Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/ISeedingCleanupCapable.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/ISeedingCleanupCapable.cs new file mode 100644 index 00000000..568b8a2c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/ISeedingCleanupCapable.cs @@ -0,0 +1,38 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Download clients whose downloads seed, and so can be evaluated against seeding rules and moved on dead-torrent detection. +/// Implemented by the torrent clients only; usenet downloads never seed. +/// +public interface ISeedingCleanupCapable : IDownloadService +{ + /// + /// Fetches all seeding downloads. + /// + /// A list of downloads that are seeding. + Task> GetSeedingDownloads(); + + /// + /// Filters downloads that should be cleaned. + /// + /// The downloads to filter. + /// The seeding rules by which to filter the downloads. + /// A list of downloads for the provided categories. + List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules); + + /// + /// Cleans the downloads. + /// + /// The downloads to clean. + /// The seeding rules. + Task CleanDownloadsAsync(List? downloads, List seedingRules); + + /// + /// Stops a download, leaving it in the client. + /// + /// The download item. + Task StopDownload(IDownloadItem item); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IUnlinkedCapable.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IUnlinkedCapable.cs new file mode 100644 index 00000000..76f6003f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/IUnlinkedCapable.cs @@ -0,0 +1,40 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Download clients whose files can be checked for hardlinks, so unlinked downloads can be moved to a target category. +/// Implemented by the torrent clients only; the *arrs move (never hardlink) usenet downloads on import. +/// +public interface IUnlinkedCapable : IDownloadService +{ + /// + /// Filters downloads that should have their category changed. + /// + /// The downloads to filter. + /// The unlinked config for this download client. + /// A list of downloads for the provided categories. + List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig); + + /// + /// Changes the category for downloads that have no hardlinks. + /// + /// The downloads to change. + /// The unlinked config for this download client. + Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig); + + /// + /// Moves a single torrent to the target category, or adds it as a tag/label when is set. + /// + /// The torrent to move. + /// The target category/tag. + /// When true, add a tag/label instead of changing the category (qBittorrent and Transmission). + Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); + + /// + /// Creates a category. + /// + /// The category name. + Task CreateCategoryAsync(string name); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs index 84eb7865..16446fd2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitItemWrapper.cs @@ -30,7 +30,7 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper .AsReadOnly()); } - public string Hash => Info.Hash ?? string.Empty; + public string DownloadId => Info.Hash ?? string.Empty; public string Name => Info.Name ?? string.Empty; @@ -140,7 +140,7 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper foreach (string pattern in ignoredDownloads) { - if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase)) + if (DownloadId.Equals(pattern, StringComparison.InvariantCultureIgnoreCase)) { return true; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs index 137f927b..7b3bef4f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs @@ -17,7 +17,7 @@ using QBittorrent.Client; namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; -public partial class QBitService : DownloadService, IDisposable +public partial class QBitService : TorrentDownloadService, IDeadTorrentCapable { protected readonly IQBittorrentClientWrapper _client; private bool? _altSpeedLimitActive; @@ -184,8 +184,13 @@ public partial class QBitService : DownloadService, IDisposable return _altSpeedLimitActive.Value; } - public override void Dispose() + protected override void Dispose(bool disposing) { - _client.Dispose(); + if (disposing) + { + _client.Dispose(); + } + + base.Dispose(disposing); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs index d7321bec..474c6b2c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs @@ -10,23 +10,23 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; public partial class QBitService { /// - public override async Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads) { - TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) + TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [downloadId] })) ?.FirstOrDefault(); BlockFilesResult result = new(); if (download is null) { - _logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } - - IReadOnlyList? trackers = await GetTrackersAsync(hash); + + IReadOnlyList? trackers = await GetTrackersAsync(downloadId); if (trackers is null) { - _logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } @@ -36,8 +36,8 @@ public partial class QBitService _logger.LogInformation("skip | download is ignored | {Name}", download.Name); return result; } - - TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); + + TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(downloadId); if (torrentProperties is null) { @@ -51,7 +51,7 @@ public partial class QBitService result.IsPrivate = isPrivate; result.Found = true; - result.Torrent = new QBitItemWrapper(download, trackers, isPrivate); + result.Item = new QBitItemWrapper(download, trackers, isPrivate); SetDownloadClientContext(); var malwareBlockerConfig = ContextProvider.Get(); @@ -63,7 +63,7 @@ public partial class QBitService return result; } - IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); + IReadOnlyList? files = await _client.GetTorrentContentsAsync(downloadId); if (files?.Count is null or 0) { @@ -91,7 +91,7 @@ public partial class QBitService { foreach (int fileIndex in unwantedIndices) { - await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); + await _client.SetFilePriorityAsync(downloadId, fileIndex, TorrentContentPriority.Skip); } }); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs index 7bbc0c95..7dc3ac98 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs @@ -41,7 +41,7 @@ public partial class QBitService } /// - public override async Task> GetAllTorrentsLite() + public override async Task> GetAllDownloadsLite() { IReadOnlyList? torrentList = await _client.GetTorrentListAsync(new TorrentListQuery()); if (torrentList is null) @@ -49,9 +49,9 @@ public partial class QBitService throw new InvalidOperationException("qBittorrent returned no torrent list"); } - List torrents = torrentList + List torrents = torrentList .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Select(ITorrentItemWrapper (t) => new QBitItemWrapper(t, [], false)) + .Select(IDownloadItem (t) => new QBitItemWrapper(t, [], false)) .ToList(); ThrowIfTorrentListCollapsed(torrentList.Count, torrents.Count); @@ -60,22 +60,22 @@ public partial class QBitService } /// - public override Task> GetClaimedPathsAsync(IReadOnlyList torrents) => - BuildClaimedPathsAsync(torrents, async torrent => + public override Task> GetClaimedPathsAsync(IReadOnlyList downloads) => + BuildClaimedPathsAsync(downloads, async torrent => { - if (string.IsNullOrEmpty(torrent.Hash)) + if (string.IsNullOrEmpty(torrent.DownloadId)) { return []; } - IReadOnlyList? files = await _client.GetTorrentContentsAsync(torrent.Hash); + IReadOnlyList? files = await _client.GetTorrentContentsAsync(torrent.DownloadId); return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? []; }); /// public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) + ?.Where(x => !string.IsNullOrEmpty(x.DownloadId)) .Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) .ToList(); @@ -83,7 +83,7 @@ public partial class QBitService public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) { return downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) + ?.Where(x => !string.IsNullOrEmpty(x.DownloadId)) .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .Where(x => { @@ -99,15 +99,15 @@ public partial class QBitService } /// - public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) + public override async Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) { - await _client.DeleteAsync([torrent.Hash], deleteSourceFiles); + await _client.DeleteAsync([item.DownloadId], deleteSourceFiles); } /// - public override async Task StopDownload(ITorrentItemWrapper torrent) + public override async Task StopDownload(IDownloadItem item) { - await _client.PauseAsync([torrent.Hash]); + await _client.PauseAsync([item.DownloadId]); } public override async Task CreateCategoryAsync(string name) @@ -128,7 +128,7 @@ public partial class QBitService protected override async Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { QBitItemWrapper qBitTorrent = (QBitItemWrapper)torrent; - IReadOnlyList? files = await _client.GetTorrentContentsAsync(qBitTorrent.Hash); + IReadOnlyList? files = await _client.GetTorrentContentsAsync(qBitTorrent.DownloadId); if (files is null) { @@ -166,7 +166,7 @@ public partial class QBitService /// protected override async Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => - await ChangeCategory(torrent.Hash, targetCategory, useTag); + await ChangeCategory(torrent.DownloadId, targetCategory, useTag); protected async Task CreateCategory(string name) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs index 58cf3016..d4510173 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs @@ -11,27 +11,27 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; public partial class QBitService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads) { DownloadCheckResult result = new(); - TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) + TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [downloadId] })) ?.FirstOrDefault(); if (download is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } - IReadOnlyList? trackers = await GetTrackersAsync(hash); + IReadOnlyList? trackers = await GetTrackersAsync(downloadId); if (trackers is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } - TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); + TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(downloadId); if (torrentProperties is null) { @@ -48,7 +48,7 @@ public partial class QBitService // Create ITorrentItem wrapper for consistent interface usage QBitItemWrapper torrent = new(download, trackers, result.IsPrivate); - result.Torrent = torrent; + result.Item = torrent; if (torrent.IsIgnored(ignoredDownloads)) { @@ -56,7 +56,7 @@ public partial class QBitService return result; } - IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); + IReadOnlyList? files = await _client.GetTorrentContentsAsync(downloadId); if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) { @@ -105,7 +105,7 @@ public partial class QBitService } bool shouldRemove = await _striker.StrikeAndCheckLimit( - wrapper.Hash, + wrapper.DownloadId, wrapper.Name, queueCleanerConfig.DownloadingMetadataMaxStrikes, StrikeType.DownloadingMetadata diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs index b8e56162..e94f5b0c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentItemWrapper.cs @@ -30,7 +30,7 @@ public sealed class RTorrentItemWrapper : ITorrentItemWrapper .AsReadOnly()); } - public string Hash => Info.Hash; + public string DownloadId => Info.Hash; public string Name => Info.Name; @@ -105,7 +105,7 @@ public sealed class RTorrentItemWrapper : ITorrentItemWrapper foreach (string pattern in ignoredDownloads) { - if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase)) + if (DownloadId.Equals(pattern, StringComparison.InvariantCultureIgnoreCase)) { return true; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentService.cs index fba7f88d..cd4813b9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentService.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent; -public partial class RTorrentService : DownloadService +public partial class RTorrentService : TorrentDownloadService { private readonly IRTorrentClientWrapper _client; @@ -102,8 +102,4 @@ public partial class RTorrentService : DownloadService }; } } - - public override void Dispose() - { - } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs index 6c1bfcf6..2a09001e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs @@ -9,17 +9,17 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent; public partial class RTorrentService { /// - public override async Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads) { // rTorrent uses uppercase hashes - hash = hash.ToUpperInvariant(); + downloadId = downloadId.ToUpperInvariant(); - RTorrentTorrent? download = await _client.GetTorrentAsync(hash); + RTorrentTorrent? download = await _client.GetTorrentAsync(downloadId); BlockFilesResult result = new(); if (download?.Hash is null) { - _logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } @@ -28,9 +28,9 @@ public partial class RTorrentService SetDownloadClientContext(); // Get trackers for ignore check - var trackers = await _client.GetTrackersAsync(hash); + var trackers = await _client.GetTrackersAsync(downloadId); var torrentWrapper = new RTorrentItemWrapper(download, trackers, _timeProvider); - result.Torrent = torrentWrapper; + result.Item = torrentWrapper; if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads)) { @@ -50,7 +50,7 @@ public partial class RTorrentService try { - files = await _client.GetTorrentFilesAsync(hash); + files = await _client.GetTorrentFilesAsync(downloadId); } catch (Exception exception) { @@ -77,7 +77,7 @@ public partial class RTorrentService { foreach (int index in unwantedIndices) { - await _client.SetFilePriorityAsync(hash, index, 0); + await _client.SetFilePriorityAsync(downloadId, index, 0); } }); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index 41c09497..ac2029d5 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -24,13 +24,13 @@ public partial class RTorrentService } /// - public override async Task> GetAllTorrentsLite() + public override async Task> GetAllDownloadsLite() { List downloads = await _client.GetAllTorrentsAsync(); - List torrents = downloads + List torrents = downloads .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x, null, _timeProvider)) + .Select(IDownloadItem (x) => new RTorrentItemWrapper(x, null, _timeProvider)) .ToList(); ThrowIfTorrentListCollapsed(downloads.Count, torrents.Count); @@ -39,11 +39,11 @@ public partial class RTorrentService } /// - public override Task> GetClaimedPathsAsync(IReadOnlyList torrents) + public override Task> GetClaimedPathsAsync(IReadOnlyList downloads) { HashSet claimed = new(StringComparer.OrdinalIgnoreCase); - foreach (ITorrentItemWrapper torrent in torrents) + foreach (IDownloadItem torrent in downloads) { if (torrent is not RTorrentItemWrapper wrapper) { @@ -65,29 +65,36 @@ public partial class RTorrentService } /// - public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) + public override async Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) { - string hash = torrent.Hash.ToUpperInvariant(); - await _client.DeleteTorrentAsync(hash); + string hash = item.DownloadId.ToUpperInvariant(); - if (deleteSourceFiles) + if (!deleteSourceFiles) { - string savePath = PathHelper.NormalizeAndRemap( - torrent.SavePath, - _downloadClientConfig.DownloadDirectorySource, - _downloadClientConfig.DownloadDirectoryTarget); - - if (!TryDeleteFiles(savePath, true)) - { - _logger.LogWarning("Failed to delete files | {name}", torrent.Name); - } + await _client.DeleteTorrentAsync(hash); + return; } + + // rTorrent keeps files open while a torrent is active, so stop it before deleting them. + await _client.StopTorrentAsync(hash); + + string savePath = PathHelper.NormalizeAndRemap( + item.SavePath, + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); + + if (!TryDeleteFiles(savePath, failOnNotFound: false)) + { + throw new IOException($"failed to delete rTorrent files | {item.Name}"); + } + + await _client.DeleteTorrentAsync(hash); } /// - public override async Task StopDownload(ITorrentItemWrapper torrent) + public override async Task StopDownload(IDownloadItem item) { - string hash = torrent.Hash.ToUpperInvariant(); + string hash = item.DownloadId.ToUpperInvariant(); await _client.StopTorrentAsync(hash); } @@ -108,7 +115,7 @@ public partial class RTorrentService try { - files = await _client.GetTorrentFilesAsync(rTorrent.Hash); + files = await _client.GetTorrentFilesAsync(rTorrent.DownloadId); } catch (Exception exception) { @@ -135,7 +142,7 @@ public partial class RTorrentService /// protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => - ChangeLabel(torrent.Hash, targetCategory); + ChangeLabel(torrent.DownloadId, targetCategory); protected virtual async Task ChangeLabel(string hash, string newLabel) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs index 3f3ab742..2fa68e3c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs @@ -8,18 +8,18 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent; public partial class RTorrentService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads) { // rTorrent uses uppercase hashes - hash = hash.ToUpperInvariant(); + downloadId = downloadId.ToUpperInvariant(); DownloadCheckResult result = new(); - RTorrentTorrent? download = await _client.GetTorrentAsync(hash); + RTorrentTorrent? download = await _client.GetTorrentAsync(downloadId); if (string.IsNullOrEmpty(download?.Hash)) { - _logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } @@ -28,9 +28,9 @@ public partial class RTorrentService SetDownloadClientContext(); // Get trackers for ignore check - var trackers = await _client.GetTrackersAsync(hash); + var trackers = await _client.GetTrackersAsync(downloadId); RTorrentItemWrapper torrent = new(download, trackers, _timeProvider); - result.Torrent = torrent; + result.Item = torrent; if (torrent.IsIgnored(ignoredDownloads)) { @@ -41,7 +41,7 @@ public partial class RTorrentService List files; try { - files = await _client.GetTorrentFilesAsync(hash); + files = await _client.GetTorrentFilesAsync(downloadId); } catch (Exception exception) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/ISabnzbdClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/ISabnzbdClientWrapper.cs new file mode 100644 index 00000000..08581fb4 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/ISabnzbdClientWrapper.cs @@ -0,0 +1,42 @@ +using Cleanuparr.Domain.Entities.Sabnzbd; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +public interface ISabnzbdClientWrapper +{ + /// + /// Confirms the configured API key is accepted. + /// + Task ValidateApiKeyAsync(); + + /// + /// Fetches the queue, optionally filtered to a single job. + /// + Task GetQueueAsync(string? nzoId = null); + + /// + /// Fetches the full history (not SABnzbd's default short window), optionally filtered to a single job. + /// + Task GetHistoryAsync(string? nzoId = null); + + /// + /// The incomplete-downloads folder SABnzbd is configured with. + /// + Task GetDownloadDirAsync(); + + /// + /// The completed-downloads folder SABnzbd is configured with. + /// + Task GetCompleteDirAsync(); + + /// + /// Deletes a job from the queue, optionally deleting its on-disk files too. + /// + Task DeleteFromQueueAsync(string nzoId, bool deleteFiles); + + /// + /// Deletes a job from history for good (forces archive=0). only + /// removes files for a Failed job; SABnzbd ignores it for a Completed job. + /// + Task DeleteFromHistoryAsync(string nzoId, bool deleteFiles); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdClient.cs new file mode 100644 index 00000000..2e20b93f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdClient.cs @@ -0,0 +1,175 @@ +using System.Text; +using System.Text.Json; +using Cleanuparr.Domain.Entities.Sabnzbd; +using Cleanuparr.Domain.Exceptions; +using Cleanuparr.Infrastructure.Json; +using Cleanuparr.Persistence.Models.Configuration; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +/// +/// Raw HTTP client for the SABnzbd API ({host}/api?mode=...&output=json&apikey=...). +/// +public sealed class SabnzbdClient +{ + private readonly DownloadClientConfig _config; + private readonly HttpClient _httpClient; + private SabnzbdMiscConfig? _cachedMiscConfig; + private bool _miscConfigFetched; + + public SabnzbdClient(DownloadClientConfig config, HttpClient httpClient) + { + _config = config; + _httpClient = httpClient; + } + + /// + /// Confirms the API key is accepted. mode=version answers without one, so login/health checks use + /// mode=queue instead, which SABnzbd rejects with HTTP 403 for a missing or wrong key. + /// + public async Task ValidateApiKeyAsync() => + await SendRequestAsync("queue", ("limit", "1")); + + /// + /// Fetches the queue, optionally filtered to a single job. + /// + public async Task GetQueueAsync(string? nzoId = null) + { + List<(string Key, string Value)> parameters = []; + + if (!string.IsNullOrEmpty(nzoId)) + { + parameters.Add(("nzo_ids", nzoId)); + } + + SabnzbdQueueResponse? response = await SendRequestAsync("queue", parameters.ToArray()); + return response?.Queue; + } + + /// + /// Fetches the full history (not SABnzbd's default short window), optionally filtered to a single job. + /// + public async Task GetHistoryAsync(string? nzoId = null) + { + // limit=0 asks SABnzbd for the full history instead of its default short window. + List<(string Key, string Value)> parameters = [("limit", "0")]; + + if (!string.IsNullOrEmpty(nzoId)) + { + parameters.Add(("nzo_ids", nzoId)); + } + + SabnzbdHistoryResponse? response = await SendRequestAsync("history", parameters.ToArray()); + return response?.History; + } + + /// + /// The incomplete-downloads folder SABnzbd is configured with (misc.download_dir). + /// + public async Task GetDownloadDirAsync() => (await GetMiscConfigAsync())?.DownloadDir; + + /// + /// The completed-downloads folder SABnzbd is configured with (misc.complete_dir). + /// + public async Task GetCompleteDirAsync() => (await GetMiscConfigAsync())?.CompleteDir; + + private async Task GetMiscConfigAsync() + { + if (_miscConfigFetched) + { + return _cachedMiscConfig; + } + + SabnzbdGetConfigResponse? response = await SendRequestAsync("get_config", ("section", "misc")); + _cachedMiscConfig = response?.Config?.Misc; + _miscConfigFetched = true; + return _cachedMiscConfig; + } + + /// + /// Deletes a job from the queue, optionally deleting its on-disk files too. + /// + public async Task DeleteFromQueueAsync(string nzoId, bool deleteFiles) => + await SendActionAsync("queue", ("name", "delete"), ("value", nzoId), ("del_files", deleteFiles ? "1" : "0")); + + /// + /// Deletes a job from history for good (forces archive=0). SABnzbd's own del_files only + /// removes files for a Failed job; it is ignored for a Completed job. + /// + public async Task DeleteFromHistoryAsync(string nzoId, bool deleteFiles) => + await SendActionAsync("history", ("name", "delete"), ("value", nzoId), ("del_files", deleteFiles ? "1" : "0"), ("archive", "0")); + + private async Task SendActionAsync(string mode, params (string Key, string Value)[] parameters) => + await SendRequestAsync(mode, parameters); + + private async Task SendRequestAsync(string mode, params (string Key, string Value)[] parameters) + { + string url = BuildUrl(mode, parameters); + HttpResponseMessage response; + + try + { + response = await _httpClient.GetAsync(url); + } + catch (HttpRequestException exception) + { + throw new SabnzbdClientException($"SABnzbd request failed for mode '{mode}': {exception.Message}", exception); + } + catch (TaskCanceledException exception) + { + // no CancellationToken is passed in, so this is always the HttpClient timeout firing. + throw new SabnzbdClientException($"SABnzbd request failed for mode '{mode}': {exception.Message}", exception); + } + + using (response) + { + string responseBody = await response.Content.ReadAsStringAsync(); + + // A bad or missing apikey comes back as plain text ("API Key Incorrect") with HTTP 403, not the + // JSON envelope checked below, so it is surfaced here from the status code. + if (!response.IsSuccessStatusCode) + { + throw new SabnzbdClientException($"SABnzbd request failed for mode '{mode}': {response.StatusCode} {Truncate(responseBody)}"); + } + + try + { + // SABnzbd reports errors as HTTP 200 with {"status":false,"error":"..."}, on any mode. + SabnzbdActionResponse? envelope = JsonSerializer.Deserialize(responseBody, CleanuparrJsonOptions.ExternalApiRead); + + if (envelope is { Status: false }) + { + throw new SabnzbdClientException($"SABnzbd request failed for mode '{mode}': {envelope.Error}"); + } + + return JsonSerializer.Deserialize(responseBody, CleanuparrJsonOptions.ExternalApiRead); + } + catch (JsonException exception) + { + throw new SabnzbdClientException($"failed to deserialize SABnzbd response for mode '{mode}': {Truncate(responseBody)}", exception); + } + } + } + + private string BuildUrl(string mode, params (string Key, string Value)[] parameters) + { + UriBuilder uriBuilder = new(_config.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api"; + + StringBuilder query = new(); + query.Append("mode=").Append(Uri.EscapeDataString(mode)); + query.Append("&output=json"); + query.Append("&apikey=").Append(Uri.EscapeDataString(_config.ApiKey ?? string.Empty)); + + foreach ((string key, string value) in parameters) + { + query.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(value)); + } + + uriBuilder.Query = query.ToString(); + return uriBuilder.Uri.ToString(); + } + + private static string Truncate(string responseJson) => + responseJson.Length > 2000 ? responseJson[..2000] : responseJson; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdClientWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdClientWrapper.cs new file mode 100644 index 00000000..f8bb2394 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdClientWrapper.cs @@ -0,0 +1,41 @@ +using Cleanuparr.Domain.Entities.Sabnzbd; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +public sealed class SabnzbdClientWrapper : ISabnzbdClientWrapper +{ + private readonly SabnzbdClient _client; + + public SabnzbdClientWrapper(SabnzbdClient client) + { + _client = client; + } + + /// + public Task ValidateApiKeyAsync() + => _client.ValidateApiKeyAsync(); + + /// + public Task GetQueueAsync(string? nzoId = null) + => _client.GetQueueAsync(nzoId); + + /// + public Task GetHistoryAsync(string? nzoId = null) + => _client.GetHistoryAsync(nzoId); + + /// + public Task GetDownloadDirAsync() + => _client.GetDownloadDirAsync(); + + /// + public Task GetCompleteDirAsync() + => _client.GetCompleteDirAsync(); + + /// + public Task DeleteFromQueueAsync(string nzoId, bool deleteFiles) + => _client.DeleteFromQueueAsync(nzoId, deleteFiles); + + /// + public Task DeleteFromHistoryAsync(string nzoId, bool deleteFiles) + => _client.DeleteFromHistoryAsync(nzoId, deleteFiles); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdItemWrapper.cs new file mode 100644 index 00000000..00b84070 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdItemWrapper.cs @@ -0,0 +1,90 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Entities.Sabnzbd; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +/// +/// Wraps a SABnzbd queue slot (in progress) or history slot (finished) as an . +/// SABnzbd has no seeding/tracker concepts, so it carries no torrent-only members. +/// +public sealed class SabnzbdItemWrapper : IDownloadItem +{ + private readonly SabnzbdQueueSlot? _queueSlot; + private readonly SabnzbdHistorySlot? _historySlot; + + /// + /// True when this item was found in SABnzbd's history (finished, successfully or not) rather than the active queue. + /// + public bool IsInHistory => _historySlot is not null; + + public SabnzbdItemWrapper(SabnzbdQueueSlot queueSlot) + { + _queueSlot = queueSlot ?? throw new ArgumentNullException(nameof(queueSlot)); + Category = queueSlot.Category; + } + + public SabnzbdItemWrapper(SabnzbdHistorySlot historySlot) + { + _historySlot = historySlot ?? throw new ArgumentNullException(nameof(historySlot)); + Category = historySlot.Category; + } + + public string DownloadId => _queueSlot?.NzoId ?? _historySlot?.NzoId ?? string.Empty; + + public string Name => _queueSlot?.Filename ?? _historySlot?.Name ?? string.Empty; + + public long Size => _historySlot is not null + ? _historySlot.Bytes + : (long)((_queueSlot?.Mb ?? 0) * 1024 * 1024); + + public double CompletionPercentage + { + get + { + if (_historySlot is not null) + { + return 100.0; + } + + double mb = _queueSlot?.Mb ?? 0; + double mbLeft = _queueSlot?.MbLeft ?? 0; + return mb > 0 ? (mb - mbLeft) / mb * 100.0 : 0.0; + } + } + + public long DownloadedBytes => _historySlot is not null + ? _historySlot.Bytes + : (long)(((_queueSlot?.Mb ?? 0) - (_queueSlot?.MbLeft ?? 0)) * 1024 * 1024); + + public string? Category { get; set; } + + public string SavePath => _historySlot?.Storage ?? string.Empty; + + /// + /// The raw SABnzbd status string, e.g. "Downloading", "Paused", "Completed", "Failed". + /// + public string? Status => _queueSlot?.Status ?? _historySlot?.Status; + + public bool IsIgnored(IReadOnlyList ignoredDownloads) + { + if (ignoredDownloads.Count == 0) + { + return false; + } + + foreach (string pattern in ignoredDownloads) + { + if (DownloadId.Equals(pattern, StringComparison.InvariantCultureIgnoreCase)) + { + return true; + } + + if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true) + { + return true; + } + } + + return false; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdService.cs new file mode 100644 index 00000000..c3fa641b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdService.cs @@ -0,0 +1,96 @@ +using Cleanuparr.Domain.Entities.HealthCheck; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Http; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Persistence.Models.Configuration; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +public partial class SabnzbdService : DownloadService +{ + private readonly ISabnzbdClientWrapper _client; + + public SabnzbdService( + ILogger logger, + IFilenameEvaluator filenameEvaluator, + IDryRunInterceptor dryRunInterceptor, + IDynamicHttpClientProvider httpClientProvider, + IEventPublisher eventPublisher, + IBlocklistProvider blocklistProvider, + DownloadClientConfig downloadClientConfig, + TimeProvider timeProvider + ) : base( + logger, filenameEvaluator, dryRunInterceptor, + httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, timeProvider + ) + { + var sabnzbdClient = new SabnzbdClient(downloadClientConfig, _httpClient); + _client = new SabnzbdClientWrapper(sabnzbdClient); + } + + // Internal constructor for testing + internal SabnzbdService( + ILogger logger, + IFilenameEvaluator filenameEvaluator, + IDryRunInterceptor dryRunInterceptor, + IDynamicHttpClientProvider httpClientProvider, + IEventPublisher eventPublisher, + IBlocklistProvider blocklistProvider, + DownloadClientConfig downloadClientConfig, + TimeProvider timeProvider, + ISabnzbdClientWrapper clientWrapper + ) : base( + logger, filenameEvaluator, dryRunInterceptor, + httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, timeProvider + ) + { + _client = clientWrapper; + } + + public override async Task LoginAsync() + { + try + { + await _client.ValidateApiKeyAsync(); + _logger.LogDebug("Successfully connected to SABnzbd client {ClientId}", _downloadClientConfig.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to SABnzbd client {ClientId}", _downloadClientConfig.Id); + throw; + } + } + + public override async Task HealthCheckAsync() + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + await _client.ValidateApiKeyAsync(); + stopwatch.Stop(); + + return new HealthCheckResult + { + IsHealthy = true, + ResponseTime = stopwatch.Elapsed + }; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogWarning(ex, "Health check failed for SABnzbd client {ClientId}", _downloadClientConfig.Id); + + return new HealthCheckResult + { + IsHealthy = false, + ErrorMessage = $"Connection failed: {ex.Message}", + ResponseTime = stopwatch.Elapsed + }; + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceCB.cs new file mode 100644 index 00000000..206e366d --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceCB.cs @@ -0,0 +1,100 @@ +using Cleanuparr.Domain.Entities.Sabnzbd; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +public partial class SabnzbdService +{ + /// + /// SABnzbd has no way to stop fetching individual files before a job finishes - the whole NZB is already pulled + /// from Usenet by the time Cleanuparr can act. Instead, this scans a completed job's output files on disk and + /// deletes the ones that match the blocklist, reusing the same scanning engine every other client uses. + /// + /// + public override async Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads) + { + BlockFilesResult result = new(); + (SabnzbdQueueSlot? queueSlot, SabnzbdHistorySlot? historySlot) = await FindAsync(downloadId); + + if (queueSlot is null && historySlot is null) + { + _logger.LogDebug("Failed to find download {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); + return result; + } + + // Still downloading, or finished but not successfully: nothing to scan yet, but it IS in this + // client, so report Found so the caller doesn't log a false "not found in any client" warning. + if (historySlot is not { Status: "Completed" } || string.IsNullOrEmpty(historySlot.Storage)) + { + result.Found = true; + result.IsPrivate = false; + result.Item = queueSlot is not null + ? new SabnzbdItemWrapper(queueSlot) + : new SabnzbdItemWrapper(historySlot!); + SetDownloadClientContext(); + _logger.LogDebug("skip | download is not a completed SABnzbd history item | {DownloadId}", downloadId); + return result; + } + + SabnzbdItemWrapper torrent = new(historySlot); + result.Found = true; + result.IsPrivate = false; + result.Item = torrent; + SetDownloadClientContext(); + + if (ignoredDownloads.Count > 0 && torrent.IsIgnored(ignoredDownloads)) + { + _logger.LogInformation("skip | download is ignored | {Name}", torrent.Name); + return result; + } + + string storagePath = RemapAndTrim(historySlot.Storage); + string[] files; + + if (File.Exists(storagePath)) + { + files = [storagePath]; + } + else if (Directory.Exists(storagePath)) + { + files = Directory.GetFiles(storagePath, "*", SearchOption.AllDirectories); + } + else + { + _logger.LogDebug("skip files check | storage not found | {Name}", torrent.Name); + return result; + } + + ContentBlockerConfig malwareBlockerConfig = ContextProvider.Get(); + + if (files.Length is 0) + { + _logger.LogDebug("skip files check | no files found | {Name}", torrent.Name); + return result; + } + + await ApplyFileBlockingAsync(result, torrent.Name, BuildScanItems(files), malwareBlockerConfig.DeleteIfAnyFileBlocked, unwantedIndices => DeleteUnwantedFiles(files, unwantedIndices)); + + return result; + } + + private static IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems(string[] files) + { + for (int i = 0; i < files.Length; i++) + { + yield return (i, Path.GetFileName(files[i]), files[i], FileBlockAction.CheckBlocklist); + } + } + + private Task DeleteUnwantedFiles(string[] files, List unwantedIndices) + { + foreach (int index in unwantedIndices) + { + TryDeleteFiles(files[index], failOnNotFound: false); + } + + return Task.CompletedTask; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceDC.cs new file mode 100644 index 00000000..32d88c8f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceDC.cs @@ -0,0 +1,232 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Entities.Sabnzbd; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +public partial class SabnzbdService +{ + /// + public override async Task> GetAllDownloadsLite() + { + SabnzbdQueueData? queue = await _client.GetQueueAsync(); + SabnzbdHistoryData? history = await _client.GetHistoryAsync(); + + if (queue is null || history is null) + { + throw new InvalidOperationException("SABnzbd returned no queue/history"); + } + + List items = []; + + foreach (SabnzbdQueueSlot slot in queue.Slots) + { + if (string.IsNullOrEmpty(slot.NzoId)) + { + continue; + } + + items.Add(new SabnzbdItemWrapper(slot)); + } + + foreach (SabnzbdHistorySlot slot in history.Slots) + { + if (string.IsNullOrEmpty(slot.NzoId)) + { + continue; + } + + items.Add(new SabnzbdItemWrapper(slot)); + } + + ThrowIfTorrentListCollapsed(queue.Slots.Count + history.Slots.Count, items.Count); + + return items; + } + + /// + /// Claims each history job's storage path, SABnzbd's own download_dir and complete_dir, plus, + /// while the client is busy (queued or post-processing), every top-level entry under download_dir. + /// + /// + public override async Task> GetClaimedPathsAsync(IReadOnlyList downloads) + { + HashSet claimed = new(BuildHistoryClaims(downloads), StringComparer.OrdinalIgnoreCase); + + bool clientIsBusy = downloads.Any(t => t is SabnzbdItemWrapper { IsInHistory: false } or SabnzbdItemWrapper { Status: not ("Completed" or "Failed") }); + + foreach (string claim in await ClaimConfiguredDirectoriesAsync()) + { + claimed.Add(claim); + } + + if (clientIsBusy) + { + foreach (string claim in await BuildIncompleteClaimsAsync()) + { + claimed.Add(claim); + } + } + + return claimed.ToList(); + } + + /// + /// Claims a history job's storage path. The scanner keeps every folder above a claim. + /// + private IEnumerable BuildHistoryClaims(IReadOnlyList downloads) + { + return downloads + .Where(x => !string.IsNullOrEmpty(x.SavePath)) + .Select(x => RemapAndTrim(x.SavePath)); + } + + /// + /// Claims download_dir and complete_dir when each resolves to an existing folder, and skips one that doesn't. + /// For a busy client, throws on an unresolvable download_dir instead. + /// + private async Task> ClaimConfiguredDirectoriesAsync() + { + List claims = []; + + foreach (Func> getDirAsync in new Func>[] { _client.GetDownloadDirAsync, _client.GetCompleteDirAsync }) + { + string? dir = await TryResolveExistingDirAsync(getDirAsync); + if (dir is not null) + { + claims.Add(dir); + } + } + + return claims; + } + + private async Task TryResolveExistingDirAsync(Func> getDirAsync) + { + string? dir = await getDirAsync(); + + if (string.IsNullOrEmpty(dir)) + { + return null; + } + + string remapped = RemapAndTrim(dir); + return Path.IsPathRooted(remapped) && Directory.Exists(remapped) ? remapped : null; + } + + /// + /// SAB sanitizes job names for incomplete folders and appends ".1"/".2" on a clash, so download_dir plus the job name can miss the real folder. + /// Claiming every entry while the client is busy (queued or post-processing) protects active downloads instead; an idle client claims none of them, so leftovers stay cleanable. + /// A busy client that can't resolve a usable download_dir throws instead, since silently claiming nothing would let the orphan scan move an in-progress job. + /// + private async Task> BuildIncompleteClaimsAsync() + { + string? downloadDir = await _client.GetDownloadDirAsync(); + + if (string.IsNullOrEmpty(downloadDir)) + { + throw new InvalidOperationException("SABnzbd download_dir is empty, skipping orphan scan while jobs are active"); + } + + string remappedDir = RemapAndTrim(downloadDir); + + if (!Path.IsPathRooted(remappedDir)) + { + throw new InvalidOperationException($"SABnzbd download_dir '{downloadDir}' is relative, skipping orphan scan while jobs are active"); + } + + if (!Directory.Exists(remappedDir)) + { + throw new InvalidOperationException($"SABnzbd download_dir '{remappedDir}' does not exist, skipping orphan scan while jobs are active"); + } + + return Directory.EnumerateFileSystemEntries(remappedDir).ToList(); + } + + /// + /// History delete always archives off (archive=0 on the client call), so the job disappears for good. + /// SABnzbd's own del_files only deletes a Failed job's files; for a Completed job we delete the + /// storage folder ourselves, the same way Sonarr does for its own usenet clients. + /// + /// + public override async Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) + { + SabnzbdItemWrapper sabnzbdItem = (SabnzbdItemWrapper)item; + + if (!sabnzbdItem.IsInHistory) + { + await _client.DeleteFromQueueAsync(item.DownloadId, deleteSourceFiles); + return; + } + + if (deleteSourceFiles && sabnzbdItem.Status == "Completed") + { + await DeleteCompletedJobFilesAsync(sabnzbdItem); + } + + await _client.DeleteFromHistoryAsync(item.DownloadId, deleteSourceFiles); + } + + /// + /// Deletes a completed job's files before clears it from history, so a failed + /// delete leaves the history entry in place for the next run to retry. + /// + private async Task DeleteCompletedJobFilesAsync(SabnzbdItemWrapper sabnzbdItem) + { + if (string.IsNullOrEmpty(sabnzbdItem.SavePath)) + { + _logger.LogDebug("skip disk delete | no storage path | {Name}", sabnzbdItem.Name); + return; + } + + string storagePath = RemapAndTrim(sabnzbdItem.SavePath); + bool isSingleFileJob = File.Exists(storagePath); + + await _dryRunInterceptor.InterceptAsync(() => + { + if (!TryDeleteFiles(storagePath, failOnNotFound: false)) + { + throw new IOException($"failed to delete SABnzbd job files | {sabnzbdItem.Name}"); + } + + if (isSingleFileJob) + { + TryDeleteEmptyJobFolder(Path.GetDirectoryName(storagePath), sabnzbdItem.Name); + } + + return Task.CompletedTask; + }); + } + + /// + /// Removes a single-file job's folder once it is empty. + /// Only a folder named after the job qualifies, so complete_dir and category folders stay. + /// The ".1"/".2" suffix SAB adds on a clash still matches. + /// + private void TryDeleteEmptyJobFolder(string? folder, string jobName) + { + if (string.IsNullOrEmpty(folder) || string.IsNullOrEmpty(jobName)) + { + return; + } + + if (!Path.GetFileName(folder).StartsWith(jobName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!Directory.Exists(folder) || Directory.EnumerateFileSystemEntries(folder).Any()) + { + return; + } + + try + { + Directory.Delete(folder); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete empty job folder | {Folder}", folder); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceQC.cs new file mode 100644 index 00000000..706c8ba3 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Sabnzbd/SabnzbdServiceQC.cs @@ -0,0 +1,66 @@ +using Cleanuparr.Domain.Entities.Sabnzbd; +using Cleanuparr.Domain.Enums; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient.Sabnzbd; + +public partial class SabnzbdService +{ + /// + public override async Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads) + { + DownloadCheckResult result = new(); + (SabnzbdQueueSlot? queueSlot, SabnzbdHistorySlot? historySlot) = await FindAsync(downloadId); + + if (queueSlot is null && historySlot is null) + { + _logger.LogDebug("Failed to find download {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); + return result; + } + + result.Found = true; + result.IsPrivate = false; + SetDownloadClientContext(); + + SabnzbdItemWrapper torrent = queueSlot is not null + ? new SabnzbdItemWrapper(queueSlot) + : new SabnzbdItemWrapper(historySlot!); + result.Item = torrent; + + if (torrent.IsIgnored(ignoredDownloads)) + { + _logger.LogDebug("skip | download is ignored | {Name}", torrent.Name); + return result; + } + + // SABnzbd fails a job that stops progressing on its own; trust that signal rather than guessing from live throughput. + if (historySlot is { Status: "Failed" }) + { + _logger.LogInformation("download failed in SABnzbd history | {Reason} | {Name}", historySlot.FailMessage, torrent.Name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.DownloadFailed; + result.DeleteFromClient = true; + } + + return result; + } + + /// + /// Looks up a job by its SABnzbd nzo_id, checking the active queue first, then history. + /// + private async Task<(SabnzbdQueueSlot? Queue, SabnzbdHistorySlot? History)> FindAsync(string nzoId) + { + SabnzbdQueueData? queue = await _client.GetQueueAsync(nzoId); + SabnzbdQueueSlot? queueSlot = queue?.Slots.FirstOrDefault(s => string.Equals(s.NzoId, nzoId, StringComparison.OrdinalIgnoreCase)); + + if (queueSlot is not null) + { + return (queueSlot, null); + } + + SabnzbdHistoryData? history = await _client.GetHistoryAsync(nzoId); + SabnzbdHistorySlot? historySlot = history?.Slots.FirstOrDefault(s => string.Equals(s.NzoId, nzoId, StringComparison.OrdinalIgnoreCase)); + + return (null, historySlot); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/TorrentDownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/TorrentDownloadService.cs new file mode 100644 index 00000000..96ba0a3c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/TorrentDownloadService.cs @@ -0,0 +1,472 @@ +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Http; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// Base for the torrent clients: everything that only makes sense for downloads that seed +/// (slow/stall rules, seeding-rule cleanup, hardlink-based unlinked moves, tags). +/// +public abstract class TorrentDownloadService : DownloadService, ISeedingCleanupCapable, IUnlinkedCapable +{ + protected readonly IStriker _striker; + protected readonly IHardLinkFileService _hardLinkFileService; + protected readonly IQueueRuleEvaluator _queueRuleEvaluator; + protected readonly ISeedingRuleEvaluator _seedingRuleEvaluator; + + protected TorrentDownloadService( + ILogger logger, + IFilenameEvaluator filenameEvaluator, + IStriker striker, + IDryRunInterceptor dryRunInterceptor, + IHardLinkFileService hardLinkFileService, + IDynamicHttpClientProvider httpClientProvider, + IEventPublisher eventPublisher, + IBlocklistProvider blocklistProvider, + DownloadClientConfig downloadClientConfig, + IQueueRuleEvaluator queueRuleEvaluator, + ISeedingRuleEvaluator seedingRuleEvaluator, + TimeProvider timeProvider + ) : base( + logger, filenameEvaluator, dryRunInterceptor, + httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, timeProvider + ) + { + _striker = striker; + _hardLinkFileService = hardLinkFileService; + _queueRuleEvaluator = queueRuleEvaluator; + _seedingRuleEvaluator = seedingRuleEvaluator; + } + + protected virtual Task IsAltSpeedLimitActiveAsync() + { + return Task.FromResult(false); + } + + /// + /// Runs the slow check, then the stall check; the first removal wins. + /// + protected async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + { + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); + + if (result.ShouldRemove) + { + return result; + } + + return await CheckIfStuck(wrapper); + } + + /// + /// Alt-speed-limit probe for the slow rules. + /// Only qBittorrent and Transmission report that state. + /// + protected virtual Func>? GetAltSpeedLimitProbe() => null; + + protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) + { + if (!wrapper.IsDownloading()) + { + _logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name); + return (false, DeleteReason.None, false, false); + } + + if (wrapper.DownloadSpeed <= 0) + { + _logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name); + return (false, DeleteReason.None, false, false); + } + + return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, GetAltSpeedLimitProbe()); + } + + protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) + { + if (!wrapper.IsStalled()) + { + _logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name); + return (false, DeleteReason.None, false, false); + } + + return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); + } + + /// + public abstract Task> GetSeedingDownloads(); + + /// + public virtual List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => + downloads + ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) + .ToList(); + + /// + public virtual List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) => + downloads + ?.Where(x => !string.IsNullOrEmpty(x.DownloadId)) + .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) + .ToList(); + + /// + public virtual async Task CleanDownloadsAsync(List? downloads, List seedingRules) + { + if (downloads?.Count is null or 0) + { + return; + } + + foreach (ITorrentItemWrapper torrent in downloads) + { + if (string.IsNullOrEmpty(torrent.DownloadId)) + { + continue; + } + + ISeedingRule? seedingRule = _seedingRuleEvaluator.GetMatchingRule(torrent, seedingRules); + + if (seedingRule is null) + { + _logger.LogTrace("No seeding rules matched | {Name}", torrent.Name); + continue; + } + + _logger.LogTrace("Seeding rule matched | {SeedingRule} | {Name}", seedingRule.Name, torrent.Name); + + if (seedingRule.Action is SeedingRuleAction.Unknown) + { + _logger.LogWarning( + "Skipping seeding rule with an action this version does not know | {SeedingRule} | {Name}", + seedingRule.Name, + torrent.Name + ); + continue; + } + + if (seedingRule.Action is SeedingRuleAction.Stop && torrent.IsStopped) + { + continue; + } + + ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); + ContextProvider.Set(ContextProvider.Keys.Hash, torrent.DownloadId); + SetDownloadClientContext(); + + TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds); + SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, torrent.SeederCount, torrent.LastActivityTime, seedingRule); + + if (!result.ShouldClean) + { + continue; + } + + bool stopping = seedingRule.Action is SeedingRuleAction.Stop; + + try + { + await _dryRunInterceptor.InterceptAsync(() => stopping + ? StopDownload(torrent) + : DeleteDownload(torrent, seedingRule.DeleteSourceFiles)); + } + catch (Exception exception) + { + // The download stays in the client. The run continues with the next download. + _logger.LogError(exception, "failed to clean download | {Name}", torrent.Name); + continue; + } + + string reason = result.Reason is CleanReason.MaxRatioReached + ? "MAX_RATIO & MIN_SEED_TIME" + : "MAX_SEED_TIME"; + + if (stopping) + { + _logger.LogInformation("download stopped | {Reason} reached | {Name}", reason, torrent.Name); + + await _eventPublisher.PublishDownloadStopped(torrent.Ratio, seedingTime, torrent.Category ?? string.Empty, result.Reason); + continue; + } + + _logger.LogInformation( + "download cleaned | {Reason} reached | delete files: {DeleteFiles} | {Name}", + reason, + seedingRule.DeleteSourceFiles, + torrent.Name + ); + + await _eventPublisher.PublishDownloadCleaned(torrent.Ratio, seedingTime, torrent.Category ?? string.Empty, result.Reason); + } + } + + /// + public async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + { + if (downloads?.Count is null or 0) + { + return; + } + + foreach (ITorrentItemWrapper torrent in downloads) + { + if (string.IsNullOrEmpty(torrent.DownloadId) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category)) + { + continue; + } + + ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); + ContextProvider.Set(ContextProvider.Keys.Hash, torrent.DownloadId); + SetDownloadClientContext(); + + IEnumerable<(string FilePath, HardLinkScanAction Action)>? files = await GetHardLinkScanItemsAsync(torrent); + + if (files is null) + { + continue; + } + + (bool hasHardlinks, bool hasErrors) = ScanForHardLinks(files, unlinkedConfig.IgnoredRootDirs.Count > 0); + + if (hasErrors) + { + continue; + } + + if (hasHardlinks) + { + _logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name); + continue; + } + + await ChangeTorrentCategoryAsync(torrent, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag); + + _logger.LogInformation(unlinkedConfig.UseTag && SupportsTags ? "tag added for {Name}" : "category changed for {Name}", torrent.Name); + } + } + + /// + /// Maps a torrent's files to (path, ) pairs. + /// Returns null to skip the torrent, such as when its files can't be read. + /// + protected abstract Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent); + + /// + /// Stops at the first hardlink or unreadable file. + /// Each client maps its files to (path, ) pairs. + /// + protected (bool HasHardlinks, bool HasErrors) ScanForHardLinks( + IEnumerable<(string FilePath, HardLinkScanAction Action)> files, + bool ignoreRootDirs) + { + foreach ((string filePath, HardLinkScanAction action) in files) + { + if (action is HardLinkScanAction.TreatAsLinked) + { + return (true, false); + } + + if (action is HardLinkScanAction.SkipUnwanted) + { + _logger.LogDebug("skip | file is not downloaded | {File}", filePath); + continue; + } + + long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, ignoreRootDirs); + + if (hardlinkCount < 0) + { + _logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath); + return (false, true); + } + + if (hardlinkCount > 0) + { + return (true, false); + } + } + + return (false, false); + } + + /// + public async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) + { + ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); + ContextProvider.Set(ContextProvider.Keys.Hash, torrent.DownloadId); + SetDownloadClientContext(); + + string currentCategory = torrent.Category ?? string.Empty; + useTag = useTag && SupportsTags; + + await _dryRunInterceptor.InterceptAsync(() => ChangeCategoryInClientAsync(torrent, targetCategory, useTag)); + + await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag); + + if (!useTag) + { + torrent.Category = targetCategory; + } + } + + /// + /// Clients without tags fall back to a category change when asked for a tag. + /// + protected virtual bool SupportsTags => false; + + /// + /// Applies the category, or the tag when is set, in the client. + /// + protected abstract Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); + + /// + public abstract Task CreateCategoryAsync(string name); + + /// + public abstract Task StopDownload(IDownloadItem item); + + private SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, int? seederCount, DateTime? lastActivity, ISeedingRule seedingRule) + { + if (BelowMinimumSeeders(seederCount, seedingRule)) + { + return new(); + } + + if (StillActiveWithinWindow(lastActivity, seedingRule)) + { + return new(); + } + + // check ratio + if (DownloadReachedRatio(ratio, seedingTime, seedingRule)) + { + return new() + { + ShouldClean = true, + Reason = CleanReason.MaxRatioReached + }; + } + + // check max seed time + if (DownloadReachedMaxSeedTime(seedingTime, seedingRule)) + { + return new() + { + ShouldClean = true, + Reason = CleanReason.MaxSeedTimeReached + }; + } + + return new(); + } + + private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, ISeedingRule seedingRule) + { + if (seedingRule.MaxRatio < 0) + { + return false; + } + + string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); + TimeSpan minSeedingTime = TimeSpan.FromHours(seedingRule.MinSeedTime); + + if (seedingRule.MinSeedTime > 0 && seedingTime < minSeedingTime) + { + _logger.LogDebug("skip | download has not reached MIN_SEED_TIME | {Name}", downloadName); + return false; + } + + if (ratio < seedingRule.MaxRatio) + { + _logger.LogDebug("skip | download has not reached MAX_RATIO | {Name}", downloadName); + return false; + } + + // max ratio is 0 or reached + return true; + } + + private bool BelowMinimumSeeders(int? seederCount, ISeedingRule seedingRule) + { + if (seedingRule is not ISeedersFilterable { MinSeeders: > 0 } seedersFilterable) + { + return false; + } + + string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); + + if (!seederCount.HasValue) + { + _logger.LogDebug("skip | download seeder count is unavailable | {Name}", downloadName); + return true; + } + + if (seederCount.Value >= seedersFilterable.MinSeeders) + { + return false; + } + + _logger.LogDebug( + "skip | download has fewer seeders than minimum | {Seeders}/{MinSeeders} | {Name}", + seederCount.Value, + seedersFilterable.MinSeeders, + downloadName); + return true; + + } + + private bool StillActiveWithinWindow(DateTime? lastActivity, ISeedingRule seedingRule) + { + if (seedingRule is not IInactivityFilterable { MaxInactiveDays: >= 0 } inactivityRule) + { + return false; + } + + string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); + + if (lastActivity is null || lastActivity <= DateTime.UnixEpoch) + { + _logger.LogDebug("skip | download last activity is unavailable | {Name}", downloadName); + return true; + } + + double inactiveDays = (_timeProvider.GetUtcNow().UtcDateTime - lastActivity.Value).TotalDays; + + if (inactiveDays < inactivityRule.MaxInactiveDays) + { + _logger.LogDebug("skip | download still active within MAX_INACTIVE_DAYS | {Name}", downloadName); + return true; + } + + return false; + } + + private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, ISeedingRule seedingRule) + { + if (seedingRule.MaxSeedTime < 0) + { + return false; + } + + string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); + TimeSpan maxSeedingTime = TimeSpan.FromHours(seedingRule.MaxSeedTime); + + if (seedingRule.MaxSeedTime > 0 && seedingTime < maxSeedingTime) + { + _logger.LogDebug("skip | download has not reached MAX_SEED_TIME | {Name}", downloadName); + return false; + } + + // max seed time is 0 or reached + return true; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs index 647d2ad7..8652ac99 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionItemWrapper.cs @@ -26,7 +26,7 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper .AsReadOnly() ?? (IReadOnlyList)Array.Empty()); } - public string Hash => Info.HashString ?? string.Empty; + public string DownloadId => Info.HashString ?? string.Empty; public string Name => Info.Name ?? string.Empty; @@ -153,7 +153,7 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper foreach (string pattern in ignoredDownloads) { - if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true) + if (DownloadId?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true) { return true; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs index d61504c3..0cdabec5 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs @@ -16,7 +16,7 @@ using Transmission.API.RPC.Entity; namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; -public partial class TransmissionService : DownloadService +public partial class TransmissionService : TorrentDownloadService, IDeadTorrentCapable { private readonly ITransmissionClientWrapper _client; private bool? _altSpeedLimitActive; @@ -202,10 +202,6 @@ public partial class TransmissionService : DownloadService return _altSpeedLimitActive.Value; } - public override void Dispose() - { - } - private async Task GetTorrentAsync(string hash) { return (await _client.TorrentGetAsync(Fields, hash)) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs index 81726aa7..c068225d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs @@ -11,14 +11,14 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; public partial class TransmissionService { /// - public override async Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads) { - TorrentInfo? download = await GetTorrentAsync(hash); + TorrentInfo? download = await GetTorrentAsync(downloadId); BlockFilesResult result = new(); if (download?.Name is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } @@ -31,7 +31,7 @@ public partial class TransmissionService bool isPrivate = download.IsPrivate ?? false; result.IsPrivate = isPrivate; result.Found = true; - result.Torrent = new TransmissionItemWrapper(download); + result.Item = new TransmissionItemWrapper(download); SetDownloadClientContext(); var malwareBlockerConfig = ContextProvider.Get(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index f72b3c92..55dc492e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -21,7 +21,7 @@ public partial class TransmissionService } /// - public override async Task> GetAllTorrentsLite() + public override async Task> GetAllDownloadsLite() { TransmissionTorrents? result = await _client.TorrentGetAsync(Fields); if (result?.Torrents is null) @@ -29,9 +29,9 @@ public partial class TransmissionService throw new InvalidOperationException("Transmission returned no torrent list"); } - List torrents = result.Torrents + List torrents = result.Torrents .Where(x => !string.IsNullOrEmpty(x.HashString)) - .Select(ITorrentItemWrapper (x) => new TransmissionItemWrapper(x)) + .Select(IDownloadItem (x) => new TransmissionItemWrapper(x)) .ToList(); ThrowIfTorrentListCollapsed(result.Torrents.Length, torrents.Count); @@ -40,8 +40,8 @@ public partial class TransmissionService } /// - public override Task> GetClaimedPathsAsync(IReadOnlyList torrents) => - BuildClaimedPathsAsync(torrents, torrent => + public override Task> GetClaimedPathsAsync(IReadOnlyList downloads) => + BuildClaimedPathsAsync(downloads, torrent => { IReadOnlyCollection files = torrent is TransmissionItemWrapper { Info.Files.Length: > 0 } wrapper ? wrapper.Info.Files @@ -56,7 +56,7 @@ public partial class TransmissionService public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) { return downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) + ?.Where(x => !string.IsNullOrEmpty(x.DownloadId)) .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .Where(x => { @@ -72,16 +72,16 @@ public partial class TransmissionService } /// - public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) + public override async Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) { - var transmissionTorrent = (TransmissionItemWrapper)torrent; + var transmissionTorrent = (TransmissionItemWrapper)item; await _client.TorrentRemoveAsync([transmissionTorrent.Info.Id], deleteSourceFiles); } /// - public override async Task StopDownload(ITorrentItemWrapper torrent) + public override async Task StopDownload(IDownloadItem item) { - TransmissionItemWrapper transmissionTorrent = (TransmissionItemWrapper)torrent; + TransmissionItemWrapper transmissionTorrent = (TransmissionItemWrapper)item; await _client.TorrentStopAsync([transmissionTorrent.Info.Id]); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs index e41b2254..b5fdcc1f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs @@ -7,14 +7,14 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; public partial class TransmissionService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads) { DownloadCheckResult result = new(); - TorrentInfo? download = await GetTorrentAsync(hash); + TorrentInfo? download = await GetTorrentAsync(downloadId); if (download is null) { - _logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } @@ -25,7 +25,7 @@ public partial class TransmissionService // Create ITorrentItem wrapper for consistent interface usage TransmissionItemWrapper torrent = new(download); - result.Torrent = torrent; + result.Item = torrent; if (torrent.IsIgnored(ignoredDownloads)) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs index 90113285..ca11a725 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentItemWrapper.cs @@ -31,7 +31,7 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper .AsReadOnly()); } - public string Hash => Info.Hash; + public string DownloadId => Info.Hash; public string Name => Info.Name; @@ -100,7 +100,7 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper foreach (string value in ignoredDownloads) { - if (Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase)) + if (DownloadId.Equals(value, StringComparison.InvariantCultureIgnoreCase)) { return true; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs index 54235f2d..580562bb 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs @@ -16,7 +16,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; /// µTorrent download service implementation /// Provides business logic layer on top of UTorrentClient /// -public partial class UTorrentService : DownloadService +public partial class UTorrentService : TorrentDownloadService, IDeadTorrentCapable { private readonly IUTorrentClientWrapper _client; @@ -86,10 +86,6 @@ public partial class UTorrentService : DownloadService _client = clientWrapper; } - public override void Dispose() - { - } - /// /// Authenticates with µTorrent Web UI /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs index b6a95ce6..0099cb1b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs @@ -11,30 +11,30 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; public partial class UTorrentService { /// - public override async Task BlockUnwantedFilesAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads) { - hash = hash.ToLowerInvariant(); + downloadId = downloadId.ToLowerInvariant(); - UTorrentItem? download = await _client.GetTorrentAsync(hash); + UTorrentItem? download = await _client.GetTorrentAsync(downloadId); BlockFilesResult result = new(); - + if (download?.Hash is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the download client", hash); + _logger.LogDebug("Failed to find torrent {DownloadId} in the download client", downloadId); return result; } - - UTorrentProperties? properties = await _client.GetTorrentPropertiesAsync(hash); + + UTorrentProperties? properties = await _client.GetTorrentPropertiesAsync(downloadId); if (properties is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the download client", hash); + _logger.LogDebug("Failed to find torrent {DownloadId} in the download client", downloadId); return result; } result.IsPrivate = properties.IsPrivate; result.Found = true; - result.Torrent = new UTorrentItemWrapper(download, properties, _timeProvider); + result.Item = new UTorrentItemWrapper(download, properties, _timeProvider); SetDownloadClientContext(); if (ignoredDownloads.Count > 0 && @@ -53,7 +53,7 @@ public partial class UTorrentService return result; } - List? files = await _client.GetTorrentFilesAsync(hash); + List? files = await _client.GetTorrentFilesAsync(downloadId); if (files?.Count is null or 0) { @@ -74,7 +74,7 @@ public partial class UTorrentService await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices => { - await _client.SetFilesPriorityAsync(hash, unwantedIndices, 0); + await _client.SetFilesPriorityAsync(downloadId, unwantedIndices, 0); }); return result; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs index c54e3696..9aa4e074 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs @@ -31,13 +31,13 @@ public partial class UTorrentService } /// - public override async Task> GetAllTorrentsLite() + public override async Task> GetAllDownloadsLite() { List reported = await _client.GetTorrentsAsync(); - List torrents = reported + List torrents = reported .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Select(ITorrentItemWrapper (x) => new UTorrentItemWrapper(x, new UTorrentProperties(), _timeProvider)) + .Select(IDownloadItem (x) => new UTorrentItemWrapper(x, new UTorrentProperties(), _timeProvider)) .ToList(); ThrowIfTorrentListCollapsed(reported.Count, torrents.Count); @@ -46,28 +46,28 @@ public partial class UTorrentService } /// - public override Task> GetClaimedPathsAsync(IReadOnlyList torrents) => - BuildClaimedPathsAsync(torrents, async torrent => + public override Task> GetClaimedPathsAsync(IReadOnlyList downloads) => + BuildClaimedPathsAsync(downloads, async torrent => { - if (string.IsNullOrEmpty(torrent.Hash)) + if (string.IsNullOrEmpty(torrent.DownloadId)) { return []; } - List? files = await _client.GetTorrentFilesAsync(torrent.Hash); + List? files = await _client.GetTorrentFilesAsync(torrent.DownloadId); return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? []; }); /// - public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) + public override async Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles) { - string hash = torrent.Hash.ToLowerInvariant(); + string hash = item.DownloadId.ToLowerInvariant(); await _client.RemoveTorrentsAsync([hash], deleteSourceFiles); } - public override async Task StopDownload(ITorrentItemWrapper torrent) + public override async Task StopDownload(IDownloadItem item) { - string hash = torrent.Hash.ToLowerInvariant(); + string hash = item.DownloadId.ToLowerInvariant(); await _client.StopTorrentsAsync([hash]); } @@ -80,7 +80,7 @@ public partial class UTorrentService protected override async Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { UTorrentItemWrapper uTorrent = (UTorrentItemWrapper)torrent; - List? files = await _client.GetTorrentFilesAsync(uTorrent.Hash); + List? files = await _client.GetTorrentFilesAsync(uTorrent.DownloadId); List<(string FilePath, HardLinkScanAction Action)> scanItems = []; @@ -101,7 +101,7 @@ public partial class UTorrentService /// protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => - ChangeLabel(torrent.Hash, targetCategory); + ChangeLabel(torrent.DownloadId, targetCategory); protected virtual async Task ChangeLabel(string hash, string newLabel) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs index d941ab5d..516cc15c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs @@ -9,24 +9,24 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; public partial class UTorrentService { /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) + public override async Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads) { List? files = null; DownloadCheckResult result = new(); - UTorrentItem? download = await _client.GetTorrentAsync(hash); + UTorrentItem? download = await _client.GetTorrentAsync(downloadId); if (download?.Hash is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } - UTorrentProperties? properties = await _client.GetTorrentPropertiesAsync(hash); + UTorrentProperties? properties = await _client.GetTorrentPropertiesAsync(downloadId); if (properties is null) { - _logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {DownloadId} in the {Name} download client", downloadId, _downloadClientConfig.Name); return result; } @@ -36,7 +36,7 @@ public partial class UTorrentService // Create ITorrentItem wrapper for consistent interface usage UTorrentItemWrapper torrent = new(download, properties, _timeProvider); - result.Torrent = torrent; + result.Item = torrent; if (torrent.IsIgnored(ignoredDownloads)) { @@ -46,11 +46,11 @@ public partial class UTorrentService try { - files = await _client.GetTorrentFilesAsync(hash); + files = await _client.GetTorrentFilesAsync(downloadId); } catch (Exception exception) { - _logger.LogWarning(exception, "Failed to get files for torrent {Hash} in the download client", hash); + _logger.LogWarning(exception, "Failed to get files for torrent {DownloadId} in the download client", downloadId); } bool shouldRemove = files?.Count > 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index 1105a037..68cea4d6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -165,7 +165,7 @@ public sealed class QueueItemRemover : IQueueItemRemover if (!target.RemovedFromClient) { _logger.LogInformation( - "search not triggered | the torrent is still in the download client | {Title}", + "search not triggered | the download is still in the download client | {Title}", target.Title ); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs index eaa1e132..4fe8ee9b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs @@ -100,7 +100,7 @@ public sealed class DownloadCleaner : GenericHandler List ignoredDownloads = ContextProvider.Get(nameof(GeneralConfig)).IgnoredDownloads; ignoredDownloads.AddRange(config.IgnoredDownloads); - Dictionary> downloadServiceToDownloadsMap = new(); + Dictionary> downloadServiceToDownloadsMap = new(); List loggedInServices = new(); foreach (IDownloadService downloadService in downloadServices) @@ -112,11 +112,18 @@ public sealed class DownloadCleaner : GenericHandler { await downloadService.LoginAsync(); loggedInServices.Add(downloadService); - List clientDownloads = await downloadService.GetSeedingDownloads(); + + // Usenet downloads do not seed - there is nothing for the seeding/unlinked/dead-torrent steps to evaluate. + if (downloadService is not ISeedingCleanupCapable seedingCapableService) + { + continue; + } + + List clientDownloads = await seedingCapableService.GetSeedingDownloads(); if (clientDownloads.Count > 0) { - downloadServiceToDownloadsMap[downloadService] = clientDownloads; + downloadServiceToDownloadsMap[seedingCapableService] = clientDownloads; } } catch (Exception ex) @@ -141,7 +148,7 @@ public sealed class DownloadCleaner : GenericHandler await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Sportarr)), true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.LazyLibrarian)), true); - foreach (KeyValuePair> pair in downloadServiceToDownloadsMap) + foreach (KeyValuePair> pair in downloadServiceToDownloadsMap) { List filteredDownloads = []; @@ -153,7 +160,7 @@ public sealed class DownloadCleaner : GenericHandler continue; } - if (_downloadsProcessedByArrs.Contains(download.Hash)) + if (_downloadsProcessedByArrs.Contains(download.DownloadId)) { _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); continue; @@ -165,20 +172,28 @@ public sealed class DownloadCleaner : GenericHandler downloadServiceToDownloadsMap[pair.Key] = filteredDownloads; } - foreach ((IDownloadService downloadService, List clientDownloads) in downloadServiceToDownloadsMap) + foreach ((ISeedingCleanupCapable downloadService, List clientDownloads) in downloadServiceToDownloadsMap) { using IDisposable _ = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString()); using IDisposable _2 = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name); - await _unlinkedService.ProcessAsync(downloadService, clientDownloads); - - try + // Every current implementor of ISeedingCleanupCapable (the torrent clients) is also IUnlinkedCapable. + if (downloadService is IUnlinkedCapable unlinkedCapableService) { - await _deadTorrentService.ProcessAsync(downloadService, clientDownloads); + await _unlinkedService.ProcessAsync(unlinkedCapableService, clientDownloads); } - catch (Exception ex) + + // rTorrent is IUnlinkedCapable but never reports a seeder count, so it is excluded here. + if (downloadService is IDeadTorrentCapable deadTorrentCapable) { - _logger.LogError(ex, "Failed to process dead torrents for download client {clientName}", downloadService.ClientConfig.Name); + try + { + await _deadTorrentService.ProcessAsync(deadTorrentCapable, clientDownloads); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process dead torrents for download client {clientName}", downloadService.ClientConfig.Name); + } } await _seedingRulesService.CleanAsync(downloadService, clientDownloads); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index 84340c2f..a383233d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -331,21 +331,21 @@ public abstract class GenericHandler : IHandler return false; } - // LazyLibrarian refuses to remove a task it adopted, and the files back another seed. + // LazyLibrarian refuses to remove a task it adopted. if (decision.Item.WasAdoptedByLazyLibrarian) { _logger.LogInformation( - "keeping torrent | LazyLibrarian adopted it | {Title} | {Hash}", + "keeping download | LazyLibrarian adopted it | {Title} | {Hash}", decision.Item.Title, decision.Item.DownloadId ); return false; } - if (decision.DownloadService is null || decision.Torrent is null) + if (decision.DownloadService is null || decision.Download is null) { _logger.LogWarning( - "skip lazylibrarian delete | torrent reference unavailable | {Title} | {Hash}", + "skip lazylibrarian delete | download reference unavailable | {Title} | {Hash}", decision.Item.Title, decision.Item.DownloadId ); @@ -354,9 +354,9 @@ public abstract class GenericHandler : IHandler try { - await _dryRunInterceptor.InterceptAsync(() => decision.DownloadService.DeleteDownload(decision.Torrent, true)); + await _dryRunInterceptor.InterceptAsync(() => decision.DownloadService.DeleteDownload(decision.Download, true)); _logger.LogInformation( - "torrent removed from download client {Client} | {Title}", + "download removed from download client {Client} | {Title}", decision.DownloadService.ClientConfig.Name, decision.Item.Title ); @@ -366,7 +366,7 @@ public abstract class GenericHandler : IHandler { _logger.LogError( exception, - "failed to remove torrent from download client {Client} | {Hash} | {Title}", + "failed to remove download from download client {Client} | {Hash} | {Title}", decision.DownloadService.ClientConfig.Name, decision.Item.DownloadId, decision.Item.Title ); @@ -403,7 +403,79 @@ public abstract class GenericHandler : IHandler { _logger.LogDebug("Initialized {Count} download clients", downloadServices.Count); } - + return downloadServices; } + + /// + /// Maps a queue record's protocol to the download client type that serves it, or null for an unknown protocol. + /// + protected static DownloadClientType? GetClientType(QueueRecord record) + { + if (record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase)) + { + return DownloadClientType.Torrent; + } + + if (record.Protocol.Contains("usenet", StringComparison.InvariantCultureIgnoreCase)) + { + return DownloadClientType.Usenet; + } + + return null; + } + + /// + /// Runs against each client of the record's protocol until one finds the download. + /// + /// The finding client's result and config, or a default result and null when no client found it. + protected async Task<(TResult Result, DownloadClientConfig? FoundIn)> FindInClientsAsync( + QueueRecord record, + IReadOnlyList downloadServices, + Func> check, + Func isFound + ) + where TCapable : IDownloadService + where TResult : new() + { + DownloadClientType? clientType = GetClientType(record); + + if (clientType is null) + { + return (new TResult(), null); + } + + string clientTypeName = clientType.Value.ToString().ToLowerInvariant(); + List matchingClients = downloadServices + .OfType() + .Where(x => x.ClientConfig.Type == clientType) + .ToList(); + + if (matchingClients.Count is 0) + { + _logger.LogDebug("No {ClientType} clients enabled", clientTypeName); + return (new TResult(), null); + } + + foreach (TCapable downloadService in matchingClients) + { + try + { + TResult result = await check(downloadService); + + if (isFound(result)) + { + return (result, downloadService.ClientConfig); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking download {DName} with download client {CName}", + record.Title, downloadService.ClientConfig.Name); + } + } + + _logger.LogWarning("Download not found in any {ClientType} client | {Title}", clientTypeName, record.Title); + return (new TResult(), null); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs index abc30b69..c05707ea 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs @@ -195,52 +195,14 @@ public sealed class MalwareBlocker : GenericHandler // push record to context ContextProvider.Set(nameof(QueueRecord), record); - BlockFilesResult result = new(); - bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase); - DownloadClientConfig? foundInClient = null; + _logger.LogDebug("searching unwanted files for {Title}", record.Title); - if (isTorrent) - { - var torrentClients = downloadServices - .Where(x => x.ClientConfig.Type is DownloadClientType.Torrent) - .ToList(); - - _logger.LogDebug("searching unwanted files for {title}", record.Title); - - if (torrentClients.Count > 0) - { - // Check each download client for the download item - foreach (var downloadService in torrentClients) - { - try - { - // stalled download check - result = await downloadService - .BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads); - - if (result.Found) - { - foundInClient = downloadService.ClientConfig; - break; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking download {dName} with download client {cName}", - record.Title, downloadService.ClientConfig.Name); - } - } - - if (!result.Found) - { - _logger.LogWarning("Download not found in any torrent client | {title}", record.Title); - } - } - else - { - _logger.LogDebug("No torrent clients enabled"); - } - } + (BlockFilesResult result, DownloadClientConfig? foundInClient) = await FindInClientsAsync( + record, + downloadServices, + downloadService => downloadService.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads), + x => x.Found + ); if (!result.ShouldRemove) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs index 70f8640c..80aeac83 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs @@ -134,10 +134,13 @@ public sealed class QueueCleaner : GenericHandler } IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version); - bool hasEnabledTorrentClients = ContextProvider - .Get>(nameof(DownloadClientConfig)) + List configuredClients = ContextProvider.Get>(nameof(DownloadClientConfig)); + bool hasEnabledTorrentClients = configuredClients .Where(x => x.Type == DownloadClientType.Torrent) .Any(x => x.Enabled); + bool hasEnabledUsenetClients = configuredClients + .Where(x => x.Type == DownloadClientType.Usenet) + .Any(x => x.Enabled); HashSet queuedDownloadIds = new(StringComparer.InvariantCultureIgnoreCase); @@ -189,46 +192,12 @@ public sealed class QueueCleaner : GenericHandler // push record to context ContextProvider.Set(nameof(QueueRecord), record); - DownloadCheckResult downloadCheckResult = new(); - bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase); - DownloadClientConfig? foundInClient = null; - - if (isTorrent) - { - var torrentClients = downloadServices - .Where(x => x.ClientConfig.Type is DownloadClientType.Torrent) - .ToList(); - - if (torrentClients.Count > 0) - { - // Check each download client for the download item - foreach (var downloadService in torrentClients) - { - try - { - // Get torrent info from download service for rule evaluation - downloadCheckResult = await downloadService - .ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads); - - if (downloadCheckResult.Found) - { - foundInClient = downloadService.ClientConfig; - break; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking download {dName} with download client {cName}", - record.Title, downloadService.ClientConfig.Name); - } - } - - if (!downloadCheckResult.Found) - { - _logger.LogWarning("Download not found in any torrent client | {title}", record.Title); - } - } - } + (DownloadCheckResult downloadCheckResult, DownloadClientConfig? foundInClient) = await FindInClientsAsync( + record, + downloadServices, + downloadService => downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads), + x => x.Found + ); if (downloadCheckResult.ShouldRemove) { @@ -258,10 +227,17 @@ public sealed class QueueCleaner : GenericHandler continue; } - // Skip failed import check if torrent is not found in client and skipIfNotFoundInClient is enabled - if (isTorrent && hasEnabledTorrentClients && !downloadCheckResult.Found && queueCleanerConfig.FailedImport.SkipIfNotFoundInClient) + // Skip failed import check if the download is not found in any client of its protocol and skipIfNotFoundInClient is enabled + bool skipIfNotFoundInClient = GetClientType(record) switch { - _logger.LogInformation("skip | torrent not found in any torrent client | {title}", record.Title); + DownloadClientType.Torrent => hasEnabledTorrentClients, + DownloadClientType.Usenet => hasEnabledUsenetClients, + _ => false + }; + + if (skipIfNotFoundInClient && !downloadCheckResult.Found && queueCleanerConfig.FailedImport.SkipIfNotFoundInClient) + { + _logger.LogInformation("skip | download not found in any client | {Title}", record.Title); continue; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianJobEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianJobEvaluator.cs index 6e701af3..4a5493a3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianJobEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianJobEvaluator.cs @@ -32,12 +32,12 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator bool ShouldRemove, bool RemoveFromClient, DeleteReason DeleteReason, - ITorrentItemWrapper? Torrent + IDownloadItem? Download ); protected abstract Task CheckAsync( IDownloadService downloadService, - string hash, + string downloadId, IReadOnlyList ignoredDownloads ); @@ -49,16 +49,6 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator { IReadOnlyList items = await _lazyLibrarianService.GetQueueAsync(instance); - List torrentClients = downloadServices - .Where(x => x.ClientConfig.Type is DownloadClientType.Torrent) - .ToList(); - - if (torrentClients.Count is 0) - { - _logger.LogDebug("No torrent clients enabled"); - return []; - } - List decisions = new(); foreach (LazyLibrarianQueueItem item in items) @@ -69,13 +59,24 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator continue; } + DownloadClientType? protocol = item.Source.ClientType(); + List matchingClients = downloadServices + .Where(x => x.ClientConfig.Type == protocol) + .ToList(); + + if (matchingClients.Count is 0) + { + _logger.LogDebug("skip | no {Protocol} clients enabled | {Title}", protocol, item.Title); + continue; + } + _logger.LogDebug("processing | {Title} | {Id}", item.Title, item.DownloadId); // The striker fires inside the download service and notifies from context. ContextProvider.Set(ContextProvider.Keys.ItemName, item.Title); ContextProvider.Set(ContextProvider.Keys.Hash, item.DownloadId); - LazyLibrarianRemovalDecision? decision = await EvaluateItemAsync(item, torrentClients, ignoredDownloads); + LazyLibrarianRemovalDecision? decision = await EvaluateItemAsync(item, matchingClients, protocol, ignoredDownloads); if (decision is not null) { @@ -88,7 +89,8 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator private async Task EvaluateItemAsync( LazyLibrarianQueueItem item, - List torrentClients, + List clients, + DownloadClientType? protocol, IReadOnlyList ignoredDownloads ) { @@ -96,7 +98,7 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator DownloadClientConfig? foundInClient = null; IDownloadService? foundInService = null; - foreach (IDownloadService downloadService in torrentClients) + foreach (IDownloadService downloadService in clients) { try { @@ -118,7 +120,8 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator if (!check.Found) { - _logger.LogWarning("Download not found in any torrent client | {Title}", item.Title); + string protocolName = protocol is DownloadClientType.Usenet ? "usenet" : "torrent"; + _logger.LogWarning("Download not found in any {Protocol} client | {Title}", protocolName, item.Title); return null; } @@ -134,7 +137,7 @@ public abstract class LazyLibrarianJobEvaluator : ILazyLibrarianEvaluator RemoveFromClient = check.RemoveFromClient, DownloadClient = foundInClient, DownloadService = foundInService, - Torrent = check.Torrent, + Download = check.Download, }; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianRemovalDecision.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianRemovalDecision.cs index ee22cbda..ec547437 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianRemovalDecision.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianRemovalDecision.cs @@ -22,5 +22,5 @@ public sealed record LazyLibrarianRemovalDecision public IDownloadService? DownloadService { get; init; } - public ITorrentItemWrapper? Torrent { get; init; } + public IDownloadItem? Download { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs index 35ead8ee..30493b6e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs @@ -63,7 +63,7 @@ public sealed class LazyLibrarianService : ILazyLibrarianService List rows = await GetHistoryAsync(instance); return rows - .Where(IsSnatchedTorrent) + .Where(IsQueryableSnatch) .Select(row => row.DownloadId!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); @@ -233,7 +233,10 @@ public sealed class LazyLibrarianService : ILazyLibrarianService .ToHashSet(StringComparer.OrdinalIgnoreCase); } - private static bool IsSnatchedTorrent(LazyLibrarianWantedRecord row) + /// + /// The snatch went to a client Cleanuparr can query (a torrent client for torrents, SABnzbd for NZBs). + /// + private static bool IsQueryableSnatch(LazyLibrarianWantedRecord row) { if (row.Status is not LazyLibrarianStatus.Snatched) { @@ -245,17 +248,19 @@ public sealed class LazyLibrarianService : ILazyLibrarianService return false; } - if (row.Mode is not (LazyLibrarianDownloadMode.Torrent or LazyLibrarianDownloadMode.Torznab or LazyLibrarianDownloadMode.Magnet)) + return row.Source.ClientType() switch { - return false; - } - - return row.Source.IsTorrentClient(); + DownloadClientType.Torrent => row.Mode is LazyLibrarianDownloadMode.Torrent + or LazyLibrarianDownloadMode.Torznab + or LazyLibrarianDownloadMode.Magnet, + DownloadClientType.Usenet => row.Mode is LazyLibrarianDownloadMode.Nzb, + _ => false, + }; } private static bool IsActionableBook(LazyLibrarianWantedRecord row) { - if (!IsSnatchedTorrent(row)) + if (!IsQueryableSnatch(row)) { return false; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceCB.cs index 01df1064..1a979630 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceCB.cs @@ -1,4 +1,5 @@ -using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Microsoft.Extensions.Logging; @@ -14,11 +15,16 @@ public sealed class LazyLibrarianServiceCB : LazyLibrarianJobEvaluator protected override async Task CheckAsync( IDownloadService downloadService, - string hash, + string downloadId, IReadOnlyList ignoredDownloads ) { - BlockFilesResult result = await downloadService.BlockUnwantedFilesAsync(hash, ignoredDownloads); + if (downloadService is not IFileBlockingCapable capable) + { + return default; + } + + BlockFilesResult result = await capable.BlockUnwantedFilesAsync(downloadId, ignoredDownloads); ContentBlockerConfig config = ContextProvider.Get(); return new ClientVerdict( @@ -26,7 +32,7 @@ public sealed class LazyLibrarianServiceCB : LazyLibrarianJobEvaluator result.ShouldRemove, !result.IsPrivate || config.DeletePrivate, result.DeleteReason, - result.Torrent + result.Item ); } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceQC.cs index dfac2354..4c4da4a3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianServiceQC.cs @@ -1,4 +1,5 @@ -using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Domain.Entities; +using Cleanuparr.Infrastructure.Features.DownloadClient; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.LazyLibrarian; @@ -12,11 +13,16 @@ public sealed class LazyLibrarianServiceQC : LazyLibrarianJobEvaluator protected override async Task CheckAsync( IDownloadService downloadService, - string hash, + string downloadId, IReadOnlyList ignoredDownloads ) { - DownloadCheckResult result = await downloadService.ShouldRemoveFromArrQueueAsync(hash, ignoredDownloads); + if (downloadService is not IQueueCheckCapable capable) + { + return default; + } + + DownloadCheckResult result = await capable.ShouldRemoveFromArrQueueAsync(downloadId, ignoredDownloads); return new ClientVerdict( result.Found, @@ -24,7 +30,7 @@ public sealed class LazyLibrarianServiceQC : LazyLibrarianJobEvaluator // LazyLibrarian has no post-import category, so a category change cannot apply. !result.IsPrivate || result.DeleteFromClient, result.DeleteReason, - result.Torrent + result.Item ); } } diff --git a/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs index 33e6211b..48a3ab9d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs @@ -56,7 +56,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator long currentDownloaded = Math.Max(0, torrent.DownloadedBytes); bool shouldRemove = await _striker.StrikeAndCheckLimit( - torrent.Hash, + torrent.DownloadId, torrent.Name, (ushort)rule.MaxStrikes, StrikeType.Stalled, @@ -100,7 +100,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (currentSpeed.Bytes < minSpeed.Bytes) { bool shouldRemove = await _striker.StrikeAndCheckLimit( - torrent.Hash, + torrent.DownloadId, torrent.Name, (ushort)rule.MaxStrikes, StrikeType.SlowSpeed @@ -125,7 +125,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (currentTime.Time.TotalSeconds > maxTime.Time.TotalSeconds && maxTime.Time.TotalSeconds > 0) { bool shouldRemove = await _striker.StrikeAndCheckLimit( - torrent.Hash, + torrent.DownloadId, torrent.Name, (ushort)rule.MaxStrikes, StrikeType.SlowTime @@ -196,7 +196,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator ); } - await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, StrikeType.Stalled); + await _striker.ResetStrikeAsync(torrent.DownloadId, torrent.Name, StrikeType.Stalled); } private async Task ResetSlowStrikesAsync( @@ -210,7 +210,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator return; } - await _striker.ResetStrikeAsync(torrent.Hash, torrent.Name, strikeType); + await _striker.ResetStrikeAsync(torrent.DownloadId, torrent.Name, strikeType); } private async Task<(bool HasProgress, long PreviousDownloaded, long CurrentDownloaded)> GetDownloadProgressAsync(ITorrentItemWrapper torrent) @@ -218,7 +218,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator long currentDownloaded = Math.Max(0, torrent.DownloadedBytes); var downloadItem = await _context.DownloadItems - .FirstOrDefaultAsync(d => d.DownloadId == torrent.Hash); + .FirstOrDefaultAsync(d => d.DownloadId == torrent.DownloadId); if (downloadItem is null) { diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261005180001_AddDownloadClientApiKey.Designer.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261005180001_AddDownloadClientApiKey.Designer.cs new file mode 100644 index 00000000..f34da479 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261005180001_AddDownloadClientApiKey.Designer.cs @@ -0,0 +1,2025 @@ +// +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("20261005180001_AddDownloadClientApiKey")] + partial class AddDownloadClientApiKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("data") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("smallint") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("uuid") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("text") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("real") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("text") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("MaxStrikes") + .HasColumnType("integer") + .HasColumnName("max_strikes"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("boolean") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_dead_torrent_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_dead_torrent_configs_download_client_config_id"); + + b.ToTable("dead_torrent_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("boolean") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("integer") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("integer") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxInactiveDays") + .HasColumnType("double precision") + .HasColumnName("max_inactive_days"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("text") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("boolean") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("double precision") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("double precision") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("double precision") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.PrimitiveCollection>("Categories") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("uuid") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection>("IgnoredRootDirs") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("boolean") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .HasColumnType("text") + .HasColumnName("api_key"); + + b.Property("DownloadDirectorySource") + .HasColumnType("text") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("text") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("text") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("text") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConnectivityCheckEnabled") + .HasColumnType("boolean") + .HasColumnName("connectivity_check_enabled"); + + b.PrimitiveCollection>("ConnectivityCheckUrls") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("connectivity_check_urls"); + + b.Property("DisplaySupportBanner") + .HasColumnType("boolean") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("boolean") + .HasColumnName("dry_run"); + + b.Property("HistoryRetentionDays") + .HasColumnType("integer") + .HasColumnName("history_retention_days"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("text") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("integer") + .HasColumnName("http_max_retries"); + + b.Property("HttpSendUserAgent") + .HasColumnType("boolean") + .HasColumnName("http_send_user_agent"); + + b.Property("HttpTimeout") + .HasColumnType("integer") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("boolean") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("integer") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("boolean") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("boolean") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("boolean") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("integer") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("integer") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("text") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("integer") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("integer") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("integer") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("boolean") + .HasColumnName("delete_if_any_file_blocked"); + + b.Property("DeletePrivate") + .HasColumnType("boolean") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("boolean") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection>("IgnoredDownloads") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("boolean") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("boolean") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "LazyLibrarian", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.LazyLibrarian#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("lazy_librarian_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("lazy_librarian_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("lazy_librarian_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sportarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sportarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("sportarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sportarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("sportarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("text") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("uuid") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("boolean") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("boolean") + .HasColumnName("on_download_cleaned"); + + b.Property("OnDownloadStopped") + .HasColumnType("boolean") + .HasColumnName("on_download_stopped"); + + b.Property("OnFailedImportStrike") + .HasColumnType("boolean") + .HasColumnName("on_failed_import_strike"); + + b.Property("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("IgnoreStruckDownloads") + .HasColumnType("boolean") + .HasColumnName("ignore_struck_downloads"); + + b.Property("LastProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("integer") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("boolean") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection>("SkipTags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("integer") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("boolean") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("boolean") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("uuid") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", "data"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dead_torrent_configs_download_clients_download_client_confi"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_confi"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_con"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_co"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_co"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261005180001_AddDownloadClientApiKey.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261005180001_AddDownloadClientApiKey.cs new file mode 100644 index 00000000..ce2add3f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/20261005180001_AddDownloadClientApiKey.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Data +{ + /// + public partial class AddDownloadClientApiKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "api_key", + schema: "data", + table: "download_clients", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "api_key", + schema: "data", + table: "download_clients"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs index d1b61b88..da12a065 100644 --- a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Data/DataContextModelSnapshot.cs @@ -657,6 +657,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data .HasColumnType("uuid") .HasColumnName("id"); + b.Property("ApiKey") + .HasColumnType("text") + .HasColumnName("api_key"); + b.Property("DownloadDirectorySource") .HasColumnType("text") .HasColumnName("download_directory_source"); diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261005180000_AddDownloadClientApiKey.Designer.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261005180000_AddDownloadClientApiKey.Designer.cs new file mode 100644 index 00000000..def416c9 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261005180000_AddDownloadClientApiKey.Designer.cs @@ -0,0 +1,2021 @@ +// +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("20261005180000_AddDownloadClientApiKey")] + partial class AddDownloadClientApiKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_dead_torrent_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_dead_torrent_configs_download_client_config_id"); + + b.ToTable("dead_torrent_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("INTEGER") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("INTEGER") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxInactiveDays") + .HasColumnType("REAL") + .HasColumnName("max_inactive_days"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ConnectivityCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("connectivity_check_enabled"); + + b.PrimitiveCollection("ConnectivityCheckUrls") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("connectivity_check_urls"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER") + .HasColumnName("history_retention_days"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpSendUserAgent") + .HasColumnType("INTEGER") + .HasColumnName("http_send_user_agent"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("INTEGER") + .HasColumnName("delete_if_any_file_blocked"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "LazyLibrarian", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.LazyLibrarian#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lazy_librarian_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lazy_librarian_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lazy_librarian_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sportarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sportarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sportarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sportarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sportarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnDownloadStopped") + .HasColumnType("INTEGER") + .HasColumnName("on_download_stopped"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("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("IgnoreStruckDownloads") + .HasColumnType("INTEGER") + .HasColumnName("ignore_struck_downloads"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DeadTorrentConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dead_torrent_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261005180000_AddDownloadClientApiKey.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261005180000_AddDownloadClientApiKey.cs new file mode 100644 index 00000000..c4707e83 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/20261005180000_AddDownloadClientApiKey.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddDownloadClientApiKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "api_key", + table: "download_clients", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "api_key", + table: "download_clients"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs index 3c2bf711..3ae8d94b 100644 --- a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Data/DataContextModelSnapshot.cs @@ -651,6 +651,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("ApiKey") + .HasColumnType("TEXT") + .HasColumnName("api_key"); + b.Property("DownloadDirectorySource") .HasColumnType("TEXT") .HasColumnName("download_directory_source"); diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs index 2223d5c9..95a66d32 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs @@ -178,6 +178,56 @@ public sealed class DownloadClientConfigTests #endregion + #region Validate - API Key Validation + + [Fact] + public void Validate_SabnzbdWithEmptyApiKey_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My Sabnzbd", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Host = new Uri("http://localhost:8080"), + ApiKey = null + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("API key cannot be empty"); + } + + [Fact] + public void Validate_SabnzbdWithApiKey_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My Sabnzbd", + TypeName = DownloadClientTypeName.Sabnzbd, + Type = DownloadClientType.Usenet, + Host = new Uri("http://localhost:8080"), + ApiKey = "a-real-key" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_QBittorrentWithEmptyApiKey_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + ApiKey = null + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + #region Url Property Tests [Fact] diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs index 0ce634d8..cff610ee 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs @@ -53,7 +53,13 @@ public sealed record DownloadClientConfig /// [SensitiveData] public string? Password { get; set; } - + + /// + /// API key for authentication, for clients that use one instead of a username/password. + /// + [SensitiveData] + public string? ApiKey { get; set; } + /// /// The base URL path component, used by clients like Transmission and Deluge /// @@ -109,5 +115,10 @@ public sealed record DownloadClientConfig { throw new ValidationException("Both download directory source and target must be set, or both must be empty"); } + + if (TypeName.RequiresAuthField(DownloadClientAuthField.ApiKey) && string.IsNullOrWhiteSpace(ApiKey)) + { + throw new ValidationException("API key cannot be empty"); + } } } diff --git a/code/frontend/src/app/core/api/download-client.api.spec.ts b/code/frontend/src/app/core/api/download-client.api.spec.ts new file mode 100644 index 00000000..277165b1 --- /dev/null +++ b/code/frontend/src/app/core/api/download-client.api.spec.ts @@ -0,0 +1,22 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { DownloadClientApi } from './download-client.api'; + +describe('DownloadClientApi', () => { + function setup() { + const get = vi.fn(() => of(null)); + TestBed.configureTestingModule({ + providers: [{ provide: HttpClient, useValue: { get } }], + }); + return { api: TestBed.inject(DownloadClientApi), get }; + } + + it('fetches the download client types from the types endpoint', () => { + const { api, get } = setup(); + + api.getTypes(); + + expect(get).toHaveBeenCalledWith('/api/configuration/download_client/types'); + }); +}); diff --git a/code/frontend/src/app/core/api/download-client.api.ts b/code/frontend/src/app/core/api/download-client.api.ts index b257e5b1..44818b90 100644 --- a/code/frontend/src/app/core/api/download-client.api.ts +++ b/code/frontend/src/app/core/api/download-client.api.ts @@ -5,9 +5,24 @@ import { DownloadClientConfig, ClientConfig, CreateDownloadClientDto, + UpdateDownloadClientDto, TestDownloadClientRequest, TestConnectionResult, + DownloadClientTypeInfo, + DownloadClientTypesResponse, } from '@shared/models/download-client-config.model'; +import { DownloadClientTypeName } from '@shared/models/enums'; + +/** Indexes a `/download_client/types` response by type name for O(1) lookups. */ +export function indexClientTypes( + response: DownloadClientTypesResponse, +): Partial> { + const result: Partial> = {}; + for (const type of response.types) { + result[type.typeName] = type; + } + return result; +} @Injectable({ providedIn: 'root' }) export class DownloadClientApi { @@ -21,7 +36,7 @@ export class DownloadClientApi { return this.http.post('/api/configuration/download_client', client); } - update(id: string, client: ClientConfig): Observable { + update(id: string, client: UpdateDownloadClientDto): Observable { return this.http.put(`/api/configuration/download_client/${id}`, client); } @@ -32,4 +47,8 @@ export class DownloadClientApi { test(request: TestDownloadClientRequest): Observable { return this.http.post('/api/configuration/download_client/test', request); } + + getTypes(): Observable { + return this.http.get('/api/configuration/download_client/types'); + } } 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 b4697539..6b499f9a 100644 --- a/code/frontend/src/app/core/feature-badges/feature-registry.ts +++ b/code/frontend/src/app/core/feature-badges/feature-registry.ts @@ -49,4 +49,5 @@ export const NEW_FEATURES: NewFeature[] = [ { id: 'seeding-rule-action' }, { id: 'change-username' }, { id: 'force-import' }, + { id: 'usenet-queue-cleaner' }, ]; diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index eec2b077..49e17e2d 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -45,6 +45,7 @@ export class DocumentationService { 'slowRule.ignoreWhileAltSpeedActive': 'slow-ignore-while-alternate-speed-active', 'slowRule.deletePrivateTorrentsFromClient': 'slow-delete-private-from-client', 'slowRule.changeCategory': 'slow-change-category', + 'usenet.removeFailed': 'usenet-remove-failed-sabnzbd-downloads', }, 'general': { 'displaySupportBanner': 'display-support-banner', diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html index e035598c..2034ab2c 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html @@ -81,6 +81,7 @@ @if (!isSelectedClientDisabled()) {
+ @if (isSeedingCleanupCapableClient()) { @if (rulesReloading()) {
@@ -166,7 +167,9 @@
+ } + @if (isUnlinkedCapableClient()) {
+ } @if (isDeadTorrentCapableClient()) { diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts index 0d915ed8..44d1b916 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts @@ -3,6 +3,7 @@ import { provideHttpClient } from '@angular/common/http'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { of, Subject, throwError } from 'rxjs'; import { DownloadCleanerApi } from '@core/api/download-cleaner.api'; +import { DownloadClientApi } from '@core/api/download-client.api'; import { ApiError } from '@core/interceptors/error.interceptor'; import { ConfirmService } from '@core/services/confirm.service'; import { ToastService } from '@core/services/toast.service'; @@ -11,7 +12,11 @@ import { OrphanedFilesConfig, SeedingRule, } from '@shared/models/download-cleaner-config.model'; -import { DownloadClientTypeName, ScheduleUnit, SeedingRuleAction, TorrentPrivacyType } from '@shared/models/enums'; +import { DownloadClientTypesResponse } from '@shared/models/download-client-config.model'; +import { + DownloadClientAuthField, DownloadClientCapability, DownloadClientTypeName, + ScheduleUnit, SeedingRuleAction, TorrentPrivacyType, +} from '@shared/models/enums'; import { DownloadCleanerComponent } from './download-cleaner.component'; const RULE_A: SeedingRule = { @@ -87,6 +92,16 @@ const CONFIG: DownloadCleanerConfig = { deadTorrentConfig: null, orphanedFilesConfig: null, }, + { + downloadClientId: 'client-sab', + downloadClientName: 'SAB box', + downloadClientEnabled: true, + downloadClientTypeName: DownloadClientTypeName.Sabnzbd, + seedingRules: [], + unlinkedConfig: null, + deadTorrentConfig: null, + orphanedFilesConfig: null, + }, ], }; @@ -103,10 +118,43 @@ function createApi(config: DownloadCleanerConfig, reloadedRules: SeedingRule[]) }; } +const TYPES: DownloadClientTypesResponse = { + types: [ + { + typeName: DownloadClientTypeName.qBittorrent, + authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + capabilities: [ + DownloadClientCapability.SeedingCleanup, DownloadClientCapability.TagFiltering, + DownloadClientCapability.SeedersFiltering, DownloadClientCapability.DeadTorrent, + DownloadClientCapability.OrphanClaims, DownloadClientCapability.Unlinked, + ], + }, + { + typeName: DownloadClientTypeName.rTorrent, + authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + capabilities: [ + DownloadClientCapability.SeedingCleanup, DownloadClientCapability.OrphanClaims, DownloadClientCapability.Unlinked, + ], + }, + { + typeName: DownloadClientTypeName.Sabnzbd, + authFields: [DownloadClientAuthField.ApiKey], + capabilities: [DownloadClientCapability.OrphanClaims], + }, + ], +}; + +function createDownloadClientApi(types: DownloadClientTypesResponse = TYPES) { + return { + getTypes: vi.fn(() => of(types)), + }; +} + interface Setup { fixture: ComponentFixture; component: DownloadCleanerComponent; api: ReturnType; + downloadClientApi: ReturnType; toast: { success: ReturnType; error: ReturnType }; confirm: ConfirmService; } @@ -127,6 +175,7 @@ describe('DownloadCleanerComponent', () => { async function setup( config: DownloadCleanerConfig = CONFIG, reloadedRules: SeedingRule[] = [RULE_B], + downloadClientApi: ReturnType = createDownloadClientApi(), ): Promise { const api = createApi(config, reloadedRules); const toast = createToast(); @@ -135,19 +184,21 @@ describe('DownloadCleanerComponent', () => { providers: [ provideHttpClient(), { provide: DownloadCleanerApi, useValue: api }, + { provide: DownloadClientApi, useValue: downloadClientApi }, { provide: ToastService, useValue: toast }, ], }); const fixture = TestBed.createComponent(DownloadCleanerComponent); fixture.detectChanges(); - await Promise.resolve(); + await fixture.whenStable(); fixture.detectChanges(); return { fixture, component: fixture.componentInstance, api, + downloadClientApi, toast, confirm: TestBed.inject(ConfirmService), }; @@ -173,6 +224,7 @@ describe('DownloadCleanerComponent', () => { expect(component.clientOptions()).toEqual([ { label: 'qBit box', value: 'client-qb' }, { label: 'rTorrent box', value: 'client-rt' }, + { label: 'SAB box', value: 'client-sab' }, ]); expect(component.hasPendingChanges()).toBe(false); @@ -266,6 +318,100 @@ describe('DownloadCleanerComponent', () => { expect(fixture.nativeElement.textContent).not.toContain('Dead Torrents'); }); + it('hides seeding rules and unlinked downloads for a seeding-incapable client but keeps orphaned files', async () => { + const { fixture, component } = await setup(); + + await component.onClientChange('client-sab'); + fixture.detectChanges(); + + expect(component.selectedClient()?.downloadClientTypeName).toBe(DownloadClientTypeName.Sabnzbd); + expect(component.isSeedingCleanupCapableClient()).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain('Seeding Rules'); + expect(fixture.nativeElement.textContent).not.toContain('Unlinked Downloads'); + expect(fixture.nativeElement.textContent).not.toContain('Dead Torrents'); + expect(fixture.nativeElement.textContent).toContain('Orphaned Files'); + }); + + it('gates seeding rules and unlinked downloads on separate capabilities', async () => { + const types: DownloadClientTypesResponse = { + types: TYPES.types.map((type) => type.typeName === DownloadClientTypeName.rTorrent + ? { ...type, capabilities: [DownloadClientCapability.Unlinked] } + : type), + }; + const { fixture, component } = await setup(CONFIG, [RULE_B], createDownloadClientApi(types)); + + await component.onClientChange('client-rt'); + fixture.detectChanges(); + + expect(component.isSeedingCleanupCapableClient()).toBe(false); + expect(component.isUnlinkedCapableClient()).toBe(true); + expect(fixture.nativeElement.textContent).not.toContain('Seeding Rules'); + expect(fixture.nativeElement.textContent).toContain('Unlinked Downloads'); + }); + + it('renders no form while the types endpoint is still loading, then renders once it resolves', async () => { + const types$ = new Subject(); + const downloadClientApi = createDownloadClientApi(); + downloadClientApi.getTypes.mockReturnValue(types$); + const api = createApi(CONFIG, [RULE_B]); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + { provide: DownloadCleanerApi, useValue: api }, + { provide: DownloadClientApi, useValue: downloadClientApi }, + ], + }); + const fixture = TestBed.createComponent(DownloadCleanerComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + + // forkJoin withholds the config too, so the whole form waits on types, not just capability gating. + expect(component.loadError()).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain('Seeding Rules'); + expect(fixture.nativeElement.textContent).not.toContain('General'); + + types$.next(TYPES); + types$.complete(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.isSeedingCleanupCapableClient()).toBe(true); + expect(fixture.nativeElement.textContent).toContain('Seeding Rules'); + }); + + it('shows the connection error state when the types endpoint fails and recovers on retry', async () => { + const downloadClientApi = createDownloadClientApi(); + downloadClientApi.getTypes.mockReturnValue(throwError(() => new Error('offline'))); + const api = createApi(CONFIG, []); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + { provide: DownloadCleanerApi, useValue: api }, + { provide: DownloadClientApi, useValue: downloadClientApi }, + ], + }); + const fixture = TestBed.createComponent(DownloadCleanerComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const component = fixture.componentInstance; + expect(component.loadError()).toBe(true); + expect(fixture.nativeElement.textContent).not.toContain('Seeding Rules'); + expect(fixture.nativeElement.textContent).toContain('Could not connect to server'); + + downloadClientApi.getTypes.mockReturnValue(of(TYPES)); + component.retry(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.loadError()).toBe(false); + expect(component.selectedClientId()).toBe('client-qb'); + }); + it('keeps the selection when the discard prompt is cancelled and drops the edits once confirmed', async () => { const { fixture, component, confirm } = await setup(); @@ -616,11 +762,15 @@ describe('DownloadCleanerComponent', () => { api.getConfig.mockReturnValue(throwError(() => new Error('offline'))); TestBed.configureTestingModule({ - providers: [provideHttpClient(), { provide: DownloadCleanerApi, useValue: api }], + providers: [ + provideHttpClient(), + { provide: DownloadCleanerApi, useValue: api }, + { provide: DownloadClientApi, useValue: createDownloadClientApi() }, + ], }); const fixture = TestBed.createComponent(DownloadCleanerComponent); fixture.detectChanges(); - await Promise.resolve(); + await fixture.whenStable(); fixture.detectChanges(); const component = fixture.componentInstance; diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts index c4537535..33f3c60c 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts @@ -1,4 +1,5 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, viewChildren, effect, untracked, linkedSignal, WritableSignal } from '@angular/core'; +import { forkJoin } from 'rxjs'; import { form, min, validate, FormField } from '@angular/forms/signals'; import { NgIconComponent } from '@ng-icons/core'; import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle, moveItemInArray } from '@angular/cdk/drag-drop'; @@ -11,6 +12,7 @@ import { type SelectOption, } from '@ui'; import { DownloadCleanerApi } from '@core/api/download-cleaner.api'; +import { DownloadClientApi, indexClientTypes } from '@core/api/download-client.api'; import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; @@ -19,8 +21,9 @@ import { DeadTorrentConfigModel, OrphanedFilesConfig, createDefaultUnlinkedConfig, createDefaultDeadTorrentConfig, createDefaultOrphanedFilesConfig, } from '@shared/models/download-cleaner-config.model'; +import { DownloadClientTypeInfo } from '@shared/models/download-client-config.model'; import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model'; -import { ScheduleUnit, DownloadClientTypeName, SeedingRuleAction } from '@shared/models/enums'; +import { ScheduleUnit, DownloadClientTypeName, DownloadClientCapability, SeedingRuleAction } from '@shared/models/enums'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; import { createDirtyTracker, DirtyTracker } from '@shared/utils/dirty-tracker.util'; @@ -84,11 +87,16 @@ interface OrphanedFilesFormModel { }) export class DownloadCleanerComponent implements HasPendingChanges { private readonly api = inject(DownloadCleanerApi); + private readonly downloadClientApi = inject(DownloadClientApi); private readonly toast = inject(ToastService); private readonly confirm = inject(ConfirmService); private readonly chipInputs = viewChildren(ChipInputComponent); private readonly seedingRuleModal = viewChild(SeedingRuleModalComponent); + private hasCapability(typeName: DownloadClientTypeName | undefined, capability: DownloadClientCapability): boolean { + return !!typeName && (this.typesByName()[typeName]?.capabilities ?? []).includes(capability); + } + private readonly orphanedFilesSnapshots = signal>({}); readonly SeedingRuleAction = SeedingRuleAction; @@ -96,10 +104,14 @@ export class DownloadCleanerComponent implements HasPendingChanges { readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS; private readonly settings = createSettingsResource({ - load: () => this.api.getConfig(), + load: () => forkJoin({ config: this.api.getConfig(), types: this.downloadClientApi.getTypes() }), errorMessage: 'Failed to load download cleaner settings', }); - private readonly configResource = this.settings.resource; + private readonly loadResource = this.settings.resource; + + /** Type info indexed by type name, from the backend; empty while loading or on error. */ + private readonly typesByName = computed>>(() => + this.loadResource.hasValue() ? indexClientTypes(this.loadResource.value().types) : {}); readonly loader = this.settings.loader; readonly loadError = this.settings.loadError; @@ -166,27 +178,22 @@ export class DownloadCleanerComponent implements HasPendingChanges { this.selectedClient()?.downloadClientTypeName === DownloadClientTypeName.Transmission ); - readonly isTagFilterableClient = computed(() => { - const typeName = this.selectedClient()?.downloadClientTypeName; - return typeName === DownloadClientTypeName.qBittorrent || typeName === DownloadClientTypeName.Transmission; - }); + readonly isTagFilterableClient = computed(() => + this.hasCapability(this.selectedClient()?.downloadClientTypeName as DownloadClientTypeName, DownloadClientCapability.TagFiltering)); - readonly isSeedersFilterableClient = computed(() => { - const typeName = this.selectedClient()?.downloadClientTypeName; - return typeName === DownloadClientTypeName.qBittorrent - || typeName === DownloadClientTypeName.Deluge - || typeName === DownloadClientTypeName.Transmission - || typeName === DownloadClientTypeName.uTorrent; - }); + readonly isSeedersFilterableClient = computed(() => + this.hasCapability(this.selectedClient()?.downloadClientTypeName as DownloadClientTypeName, DownloadClientCapability.SeedersFiltering)); // Dead torrent detection needs a seeder count; rTorrent does not report one. - readonly isDeadTorrentCapableClient = computed(() => { - const typeName = this.selectedClient()?.downloadClientTypeName; - return typeName === DownloadClientTypeName.qBittorrent - || typeName === DownloadClientTypeName.Deluge - || typeName === DownloadClientTypeName.Transmission - || typeName === DownloadClientTypeName.uTorrent; - }); + readonly isDeadTorrentCapableClient = computed(() => + this.hasCapability(this.selectedClient()?.downloadClientTypeName as DownloadClientTypeName, DownloadClientCapability.DeadTorrent)); + + /** Seeding rules need seeding support (SABnzbd has none). */ + readonly isSeedingCleanupCapableClient = computed(() => + this.hasCapability(this.selectedClient()?.downloadClientTypeName as DownloadClientTypeName, DownloadClientCapability.SeedingCleanup)); + + readonly isUnlinkedCapableClient = computed(() => + this.hasCapability(this.selectedClient()?.downloadClientTypeName as DownloadClientTypeName, DownloadClientCapability.Unlinked)); readonly seedingRulesExpanded = signal(false); readonly unlinkedExpanded = signal(false); @@ -321,7 +328,7 @@ export class DownloadCleanerComponent implements HasPendingChanges { }); effect(() => { - const dc = this.configResource.hasValue() ? this.configResource.value() : undefined; + const dc = this.loadResource.hasValue() ? this.loadResource.value().config : undefined; if (!dc) { return; } diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html index e14de45c..99999d4e 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html @@ -81,6 +81,12 @@ [hint]="passwordHint()" helpKey="download-client:password" /> } + @if (showApiKeyField()) { + + } diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts index bf803395..8bc2a6be 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts @@ -1,11 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { ApiError } from '@core/interceptors/error.interceptor'; import { DownloadClientApi } from '@core/api/download-client.api'; import { ConfirmService } from '@core/services/confirm.service'; import { ToastService } from '@core/services/toast.service'; -import { ClientConfig, DownloadClientConfig } from '@shared/models/download-client-config.model'; -import { DownloadClientType, DownloadClientTypeName } from '@shared/models/enums'; +import { ClientConfig, DownloadClientConfig, DownloadClientTypesResponse } from '@shared/models/download-client-config.model'; +import { DownloadClientAuthField, DownloadClientCapability, DownloadClientType, DownloadClientTypeName } from '@shared/models/enums'; import { DownloadClientsComponent } from './download-clients.component'; const QBIT: ClientConfig = { @@ -38,13 +38,49 @@ const DELUGE: ClientConfig = { const CONFIG: DownloadClientConfig = { clients: [QBIT, DELUGE] }; -function createApi(config: DownloadClientConfig = CONFIG) { +const TYPES: DownloadClientTypesResponse = { + types: [ + { + typeName: DownloadClientTypeName.qBittorrent, + authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + capabilities: [DownloadClientCapability.SeedingCleanup, DownloadClientCapability.TagFiltering], + }, + { + typeName: DownloadClientTypeName.Deluge, + authFields: [DownloadClientAuthField.Password], + capabilities: [DownloadClientCapability.SeedingCleanup], + }, + { + typeName: DownloadClientTypeName.Transmission, + authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + capabilities: [DownloadClientCapability.SeedingCleanup, DownloadClientCapability.TagFiltering], + }, + { + typeName: DownloadClientTypeName.uTorrent, + authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + capabilities: [DownloadClientCapability.SeedingCleanup], + }, + { + typeName: DownloadClientTypeName.rTorrent, + authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password], + capabilities: [], + }, + { + typeName: DownloadClientTypeName.Sabnzbd, + authFields: [DownloadClientAuthField.ApiKey], + capabilities: [DownloadClientCapability.OrphanClaims], + }, + ], +}; + +function createApi(config: DownloadClientConfig = CONFIG, types: DownloadClientTypesResponse = TYPES) { return { getConfig: vi.fn(() => of(config)), create: vi.fn(() => of(QBIT)), update: vi.fn(() => of(QBIT)), delete: vi.fn(() => of(undefined)), test: vi.fn(() => of({ message: 'Connected to qBittorrent 4.6.0' })), + getTypes: vi.fn(() => of(types)), }; } @@ -158,6 +194,7 @@ describe('DownloadClientsComponent', () => { host: '', username: '', password: '', + apiKey: '', urlBase: '', externalUrl: '', downloadDirectorySource: '', @@ -198,6 +235,111 @@ describe('DownloadClientsComponent', () => { expect(fieldLabels(fixture)).toContain('Password'); }); + it('hides username/password and shows a required API key field for SABnzbd', () => { + const { fixture, component } = setup(); + + component.openAddModal(); + fixture.detectChanges(); + + chooseClientType(fixture, 'SABnzbd'); + + expect(component.clientModel().typeName).toBe(DownloadClientTypeName.Sabnzbd); + expect(component.showUsernameField()).toBe(false); + expect(component.showPasswordField()).toBe(false); + expect(component.showApiKeyField()).toBe(true); + expect(fieldLabels(fixture)).not.toContain('Username'); + expect(fieldLabels(fixture)).not.toContain('Password'); + expect(fieldLabels(fixture)).toContain('API Key'); + }); + + it('blocks saving a SABnzbd client with an empty API key and shows the error', () => { + const { fixture, component, api } = setup(); + + component.openAddModal(); + component.clientForm.name().value.set('SAB box'); + component.clientForm.host().value.set('http://localhost:8080'); + fixture.detectChanges(); + + chooseClientType(fixture, 'SABnzbd'); + + expect(component.hasModalErrors()).toBe(true); + expect(component.clientForm.apiKey().errors()[0].message).toBe('API key is required'); + + component.saveClient(); + expect(api.create).not.toHaveBeenCalled(); + }); + + it('does not require an API key for qBittorrent with an empty key', () => { + const { fixture, component } = setup(); + + component.openAddModal(); + component.clientForm.name().value.set('New client'); + component.clientForm.host().value.set('http://localhost:8080'); + fixture.detectChanges(); + + expect(component.clientModel().typeName).toBe(DownloadClientTypeName.qBittorrent); + expect(component.clientForm.apiKey().value()).toBe(''); + expect(component.hasModalErrors()).toBe(false); + }); + + it('shows no auth fields while the types endpoint is still loading', async () => { + const types$ = new Subject(); + const api = createApi(); + api.getTypes.mockReturnValue(types$); + const { fixture, component } = setup(api); + + component.openAddModal(); + fixture.detectChanges(); + + expect(component.showUsernameField()).toBe(false); + expect(component.showPasswordField()).toBe(false); + expect(component.showApiKeyField()).toBe(false); + expect(fieldLabels(fixture)).not.toContain('Username'); + expect(fieldLabels(fixture)).not.toContain('Password'); + + types$.next(TYPES); + types$.complete(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.showUsernameField()).toBe(true); + expect(component.showPasswordField()).toBe(true); + }); + + it('does not show the API key field for clients that do not use one', () => { + const { fixture, component } = setup(); + + component.openAddModal(); + fixture.detectChanges(); + + expect(component.clientModel().typeName).toBe(DownloadClientTypeName.qBittorrent); + expect(component.showApiKeyField()).toBe(false); + expect(fieldLabels(fixture)).not.toContain('API Key'); + }); + + it('saves a SABnzbd client with the Usenet client type', () => { + const { fixture, component, api } = setup(); + + component.openAddModal(); + component.clientForm.name().value.set('SAB box'); + component.clientForm.host().value.set('http://localhost:8080'); + fixture.detectChanges(); + + chooseClientType(fixture, 'SABnzbd'); + component.clientForm.apiKey().value.set('sab-key'); + fixture.detectChanges(); + + component.saveClient(); + fixture.detectChanges(); + + expect(api.create).toHaveBeenCalledWith( + expect.objectContaining({ + typeName: DownloadClientTypeName.Sabnzbd, + apiKey: 'sab-key', + }), + ); + }); + it('autofills the url base and switches the hints to HTTP Basic Auth for rTorrent', () => { const { fixture, component } = setup(); @@ -265,6 +407,7 @@ describe('DownloadClientsComponent', () => { host: 'http://localhost:8112', username: 'legacy', password: '', + apiKey: '', urlBase: '', externalUrl: '', downloadDirectorySource: '', @@ -290,11 +433,11 @@ describe('DownloadClientsComponent', () => { expect(api.create).toHaveBeenCalledWith({ enabled: true, name: 'New client', - type: DownloadClientType.Torrent, typeName: DownloadClientTypeName.Transmission, host: 'http://localhost:9091', username: '', password: '', + apiKey: '', urlBase: 'transmission', externalUrl: undefined, downloadDirectorySource: null, @@ -321,13 +464,12 @@ describe('DownloadClientsComponent', () => { expect(api.update).toHaveBeenCalledWith('client-qb', { enabled: true, - id: 'client-qb', name: 'Renamed', - type: DownloadClientType.Torrent, typeName: DownloadClientTypeName.qBittorrent, host: 'http://localhost:8080', username: 'admin', password: undefined, + apiKey: undefined, urlBase: '', externalUrl: undefined, downloadDirectorySource: null, @@ -364,10 +506,10 @@ describe('DownloadClientsComponent', () => { expect(api.test).toHaveBeenCalledWith({ typeName: DownloadClientTypeName.qBittorrent, - type: DownloadClientType.Torrent, host: 'http://localhost:8080', username: 'admin', password: 'secret', + apiKey: '', urlBase: '', clientId: 'client-qb', }); @@ -460,6 +602,24 @@ describe('DownloadClientsComponent', () => { expect(text(fixture, '.item-row__name')).toEqual(['qBit box', 'Deluge box']); }); + it('shows the connection error state when the types endpoint fails and recovers on retry', () => { + const api = createApi(); + api.getTypes.mockReturnValue(throwError(() => new Error('offline'))); + const { fixture, component, toast } = setup(api); + + expect(component.loadError()).toBe(true); + expect(component.clients()).toEqual([]); + expect(toast.error).toHaveBeenCalledWith('Failed to load download clients: offline'); + expect(fixture.nativeElement.textContent).toContain('Could not connect to server'); + + api.getTypes.mockReturnValue(of(TYPES)); + component.retry(); + fixture.detectChanges(); + + expect(component.loadError()).toBe(false); + expect(text(fixture, '.item-row__name')).toEqual(['qBit box', 'Deluge box']); + }); + it('reports a failed create and keeps the modal open', () => { const api = createApi(); api.create.mockReturnValue(throwError(() => new ApiError('Invalid port'))); diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts index df93ad0f..7fe17666 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts @@ -1,4 +1,5 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core'; +import { forkJoin } from 'rxjs'; import { form, required, FormField } from '@angular/forms/signals'; import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { @@ -6,14 +7,14 @@ import { SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent, type SelectOption, } from '@ui'; -import { DownloadClientApi } from '@core/api/download-client.api'; +import { DownloadClientApi, indexClientTypes } from '@core/api/download-client.api'; import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { - ClientConfig, CreateDownloadClientDto, TestDownloadClientRequest, + DownloadClientTypeInfo, ClientConfig, CreateDownloadClientDto, UpdateDownloadClientDto, TestDownloadClientRequest, } from '@shared/models/download-client-config.model'; -import { DownloadClientType, DownloadClientTypeName } from '@shared/models/enums'; +import { DownloadClientAuthField, DownloadClientTypeName } from '@shared/models/enums'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; import { createSettingsResource } from '@shared/utils/settings-resource.util'; @@ -23,6 +24,7 @@ const TYPE_OPTIONS: SelectOption[] = [ { label: 'Transmission', value: DownloadClientTypeName.Transmission }, { label: 'uTorrent', value: DownloadClientTypeName.uTorrent }, { label: 'rTorrent', value: DownloadClientTypeName.rTorrent }, + { label: 'SABnzbd', value: DownloadClientTypeName.Sabnzbd }, ]; const AUTOFILL_URL_BASES: Partial> = { @@ -30,6 +32,14 @@ const AUTOFILL_URL_BASES: Partial> = { [DownloadClientTypeName.rTorrent]: 'plugins/httprpc/action.php', }; +type AuthField = 'username' | 'password' | 'apiKey'; + +const AUTH_FIELD_BY_API_FIELD: Record = { + [DownloadClientAuthField.Username]: 'username', + [DownloadClientAuthField.Password]: 'password', + [DownloadClientAuthField.ApiKey]: 'apiKey', +}; + interface DownloadClientFormModel { enabled: boolean; name: string; @@ -37,6 +47,7 @@ interface DownloadClientFormModel { host: string; username: string; password: string; + apiKey: string; urlBase: string; externalUrl: string; downloadDirectorySource: string; @@ -61,17 +72,29 @@ export class DownloadClientsComponent implements HasPendingChanges { private readonly confirmService = inject(ConfirmService); private readonly settings = createSettingsResource({ - load: () => this.api.getConfig(), + load: () => forkJoin({ config: this.api.getConfig(), types: this.api.getTypes() }), errorMessage: 'Failed to load download clients', }); - private readonly clientsResource = this.settings.resource; + private readonly loadResource = this.settings.resource; + + /** Type info indexed by type name, from the backend; empty while loading or on error. */ + private readonly typesByName = computed>>(() => + this.loadResource.hasValue() ? indexClientTypes(this.loadResource.value().types) : {}); + + private readonly authFieldsByType = computed>>(() => { + const result: Partial> = {}; + for (const [typeName, info] of Object.entries(this.typesByName()) as [DownloadClientTypeName, DownloadClientTypeInfo][]) { + result[typeName] = info.authFields.map((f) => AUTH_FIELD_BY_API_FIELD[f]); + } + return result; + }); readonly typeOptions = TYPE_OPTIONS; readonly loader = this.settings.loader; readonly loadError = this.settings.loadError; readonly saving = signal(false); readonly clients = computed(() => - this.clientsResource.hasValue() ? (this.clientsResource.value().clients ?? []) : [], + this.loadResource.hasValue() ? (this.loadResource.value().config.clients ?? []) : [], ); // Modal @@ -81,12 +104,16 @@ export class DownloadClientsComponent implements HasPendingChanges { readonly clientModel = signal({ enabled: true, name: '', typeName: DownloadClientTypeName.qBittorrent, - host: '', username: '', password: '', urlBase: '', externalUrl: '', + host: '', username: '', password: '', apiKey: '', urlBase: '', externalUrl: '', downloadDirectorySource: '', downloadDirectoryTarget: '', }); readonly clientForm = form(this.clientModel, (p) => { required(p.name, { message: 'Name is required' }); required(p.host, { message: 'Host is required' }); + required(p.apiKey, { + when: ({ valueOf }) => valueOf(p.typeName) === DownloadClientTypeName.Sabnzbd, + message: 'API key is required', + }); }); readonly hasModalErrors = computed(() => this.clientForm().invalid()); @@ -96,11 +123,16 @@ export class DownloadClientsComponent implements HasPendingChanges { private readonly modalDirty = computed(() => this.modalVisible() && JSON.stringify(this.clientModel()) !== this.openSnapshot()); - readonly showUsernameField = computed(() => { - return this.clientModel().typeName !== DownloadClientTypeName.Deluge; - }); + readonly showUsernameField = computed(() => + (this.authFieldsByType()[this.clientModel().typeName] ?? []).includes('username')); - readonly showPasswordField = computed(() => true); + readonly showPasswordField = computed(() => + (this.authFieldsByType()[this.clientModel().typeName] ?? []).includes('password')); + + readonly showApiKeyField = computed(() => + (this.authFieldsByType()[this.clientModel().typeName] ?? []).includes('apiKey')); + + readonly apiKeyHint = 'API key from SABnzbd > Config > General'; readonly usernameHint = computed(() => { if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) { @@ -129,9 +161,16 @@ export class DownloadClientsComponent implements HasPendingChanges { const newType = value as DownloadClientTypeName; const m = this.clientModel(); const patch: Partial = {}; - if (newType === DownloadClientTypeName.Deluge && m.username !== '') { + const fields = this.authFieldsByType()[newType] ?? []; + if (!fields.includes('username') && m.username !== '') { patch.username = ''; } + if (!fields.includes('password') && m.password !== '') { + patch.password = ''; + } + if (!fields.includes('apiKey') && m.apiKey !== '') { + patch.apiKey = ''; + } const autofill = AUTOFILL_URL_BASES[newType]; const replaceable = m.urlBase === '' || Object.values(AUTOFILL_URL_BASES).includes(m.urlBase); if (replaceable && (autofill ?? '') !== m.urlBase) { @@ -150,7 +189,7 @@ export class DownloadClientsComponent implements HasPendingChanges { this.editingClient.set(null); this.clientModel.set({ enabled: true, name: '', typeName: DownloadClientTypeName.qBittorrent, - host: '', username: '', password: '', urlBase: '', externalUrl: '', + host: '', username: '', password: '', apiKey: '', urlBase: '', externalUrl: '', downloadDirectorySource: '', downloadDirectoryTarget: '', }); this.openSnapshot.set(JSON.stringify(this.clientModel())); @@ -166,6 +205,7 @@ export class DownloadClientsComponent implements HasPendingChanges { host: client.host, username: client.username, password: client.password ?? '', + apiKey: client.apiKey ?? '', urlBase: client.urlBase, externalUrl: client.externalUrl ?? '', downloadDirectorySource: client.downloadDirectorySource ?? '', @@ -179,10 +219,10 @@ export class DownloadClientsComponent implements HasPendingChanges { const m = this.clientModel(); const request: TestDownloadClientRequest = { typeName: m.typeName, - type: DownloadClientType.Torrent, host: m.host, username: m.username, password: m.password, + apiKey: m.apiKey, urlBase: m.urlBase, clientId: this.editingClient()?.id, }; @@ -208,14 +248,14 @@ export class DownloadClientsComponent implements HasPendingChanges { this.saving.set(true); if (editing) { - const client: ClientConfig = { - ...editing, + const client: UpdateDownloadClientDto = { enabled: m.enabled, name: m.name, typeName: m.typeName, host: m.host, username: m.username, password: m.password || undefined, + apiKey: m.apiKey || undefined, urlBase: m.urlBase, externalUrl: m.externalUrl || undefined, downloadDirectorySource: m.downloadDirectorySource || null, @@ -226,7 +266,7 @@ export class DownloadClientsComponent implements HasPendingChanges { this.toast.success('Client updated'); this.modalVisible.set(false); this.saving.set(false); - this.clientsResource.reload(); + this.loadResource.reload(); }, error: (err: ApiError) => { this.toast.error(err.message); @@ -237,11 +277,11 @@ export class DownloadClientsComponent implements HasPendingChanges { const dto: CreateDownloadClientDto = { enabled: m.enabled, name: m.name, - type: DownloadClientType.Torrent, typeName: m.typeName, host: m.host, username: m.username, password: m.password, + apiKey: m.apiKey, urlBase: m.urlBase, externalUrl: m.externalUrl || undefined, downloadDirectorySource: m.downloadDirectorySource || null, @@ -252,7 +292,7 @@ export class DownloadClientsComponent implements HasPendingChanges { this.toast.success('Client added'); this.modalVisible.set(false); this.saving.set(false); - this.clientsResource.reload(); + this.loadResource.reload(); }, error: (err: ApiError) => { this.toast.error(err.message); @@ -276,7 +316,7 @@ export class DownloadClientsComponent implements HasPendingChanges { this.api.delete(client.id).subscribe({ next: () => { this.toast.success('Client deleted'); - this.clientsResource.reload(); + this.loadResource.reload(); }, error: (err: ApiError) => this.toast.error(err.message), }); diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html index ec77dc51..4618ce23 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html @@ -80,7 +80,7 @@ helpKey="queue-cleaner:failedImport.deletePrivate" /> }
@@ -243,6 +243,16 @@
+ + +
+ +
+
+ }
diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts index c3c3fd9f..c3eafee3 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts @@ -469,6 +469,26 @@ describe('QueueCleanerComponent', () => { expect(api.getConfig).toHaveBeenCalledTimes(2); }); + it('renders the Usenet toggle checked and disabled, hides it when Enabled is off, and stays clean', () => { + const { fixture, component } = setup(); + + const usenetAccordion = fixture.nativeElement.querySelector('app-accordion:last-of-type'); + expect(usenetAccordion?.textContent).toContain('Usenet'); + + component.usenetExpanded.set(true); + fixture.detectChanges(); + + const usenetToggle = fixture.nativeElement.querySelector('app-toggle[helpkey="queue-cleaner:usenet.removeFailed"] button'); + expect(usenetToggle.getAttribute('aria-checked')).toBe('true'); + expect(usenetToggle.disabled).toBe(true); + expect(component.dirty()).toBe(false); + + component.qcForm.enabled().value.set(false); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-toggle[helpkey="queue-cleaner:usenet.removeFailed"]')).toBeNull(); + }); + it('toasts which rule load failed alongside the backend message', () => { const api = createApi(CONFIG, STALL_RULES, SLOW_RULES); api.getStallRules.mockReturnValue(throwError(() => new ApiError('stall boom'))); diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts index 12a22735..54883b57 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts @@ -191,6 +191,8 @@ export class QueueCleanerComponent implements HasPendingChanges { readonly slowModalVisible = signal(false); readonly editingSlowRule = signal(null); + readonly usenetExpanded = signal(false); + private readonly dirtyTracker = createDirtyTracker(this.model); readonly dirty = this.dirtyTracker.dirty; diff --git a/code/frontend/src/app/shared/models/download-client-config.model.ts b/code/frontend/src/app/shared/models/download-client-config.model.ts index e031142f..d79c11fa 100644 --- a/code/frontend/src/app/shared/models/download-client-config.model.ts +++ b/code/frontend/src/app/shared/models/download-client-config.model.ts @@ -1,4 +1,4 @@ -import { DownloadClientType, DownloadClientTypeName } from './enums'; +import { DownloadClientAuthField, DownloadClientCapability, DownloadClientType, DownloadClientTypeName } from './enums'; export interface ClientConfig { enabled: boolean; @@ -9,6 +9,7 @@ export interface ClientConfig { host: string; username: string; password?: string; + apiKey?: string; urlBase: string; externalUrl?: string; downloadDirectorySource?: string | null; @@ -22,11 +23,25 @@ export interface DownloadClientConfig { export interface CreateDownloadClientDto { enabled: boolean; name: string; - type: DownloadClientType; typeName: DownloadClientTypeName; host?: string; username?: string; password?: string; + apiKey?: string; + urlBase?: string; + externalUrl?: string; + downloadDirectorySource?: string | null; + downloadDirectoryTarget?: string | null; +} + +export interface UpdateDownloadClientDto { + enabled: boolean; + name: string; + typeName: DownloadClientTypeName; + host?: string; + username?: string; + password?: string; + apiKey?: string; urlBase?: string; externalUrl?: string; downloadDirectorySource?: string | null; @@ -35,10 +50,10 @@ export interface CreateDownloadClientDto { export interface TestDownloadClientRequest { typeName: DownloadClientTypeName; - type: DownloadClientType; host?: string; username?: string; password?: string; + apiKey?: string; urlBase?: string; clientId?: string; } @@ -47,3 +62,14 @@ export interface TestConnectionResult { message: string; responseTime?: number; } + +/** What a download client type's connection form needs and what its service can do. */ +export interface DownloadClientTypeInfo { + typeName: DownloadClientTypeName; + authFields: DownloadClientAuthField[]; + capabilities: DownloadClientCapability[]; +} + +export interface DownloadClientTypesResponse { + types: DownloadClientTypeInfo[]; +} diff --git a/code/frontend/src/app/shared/models/enums.ts b/code/frontend/src/app/shared/models/enums.ts index d258ea5c..b6787944 100644 --- a/code/frontend/src/app/shared/models/enums.ts +++ b/code/frontend/src/app/shared/models/enums.ts @@ -9,6 +9,24 @@ export enum DownloadClientTypeName { Transmission = 'Transmission', uTorrent = 'uTorrent', rTorrent = 'rTorrent', + Sabnzbd = 'Sabnzbd', +} + +export enum DownloadClientAuthField { + Username = 'Username', + Password = 'Password', + ApiKey = 'ApiKey', +} + +export enum DownloadClientCapability { + QueueCheck = 'QueueCheck', + FileBlocking = 'FileBlocking', + OrphanClaims = 'OrphanClaims', + SeedingCleanup = 'SeedingCleanup', + Unlinked = 'Unlinked', + TagFiltering = 'TagFiltering', + SeedersFiltering = 'SeedersFiltering', + DeadTorrent = 'DeadTorrent', } export enum NotificationProviderType { @@ -122,6 +140,7 @@ export enum DeleteReason { AllFilesSkippedByQBit = 'AllFilesSkippedByQBit', AllFilesBlocked = 'AllFilesBlocked', AtLeastOneFileBlocked = 'AtLeastOneFileBlocked', + DownloadFailed = 'DownloadFailed', } export enum InstanceType { diff --git a/docs/docs/3_supported-apps.mdx b/docs/docs/3_supported-apps.mdx index 4e883dc2..f8c3f3b1 100644 --- a/docs/docs/3_supported-apps.mdx +++ b/docs/docs/3_supported-apps.mdx @@ -5,6 +5,7 @@ sidebar_position: 3 import { Important } from '@site/src/components/documentation'; import { AppCard, + CapabilityMatrix, styles } from '@site/src/components/documentation'; import useBaseUrl from '@docusaurus/useBaseUrl'; @@ -13,6 +14,74 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; Cleanuparr integrates with the servarr applications and download clients for media management automation. +export const capabilityClients = [ + { name: 'qBittorrent', icon: 'qbittorrent' }, + { name: 'Deluge', icon: 'deluge' }, + { name: 'Transmission', icon: 'transmission' }, + { name: 'uTorrent', icon: 'utorrent' }, + { name: 'rTorrent', icon: 'rtorrent' }, + { name: 'SABnzbd', icon: 'sabnzbd' }, +]; + +export const capabilityGroups = [ + { + title: 'Queue Cleaner', + rows: [ + { feature: 'Failed imports', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'yes'] }, + { feature: 'Stalled downloads', cells: [{ status: 'yes' }, { status: 'yes', note: 1 }, { status: 'yes', note: 1 }, { status: 'yes', note: 1 }, { status: 'yes', note: 1 }, { status: 'na' }] }, + { feature: 'Failed downloads', cells: ['na', 'na', 'na', 'na', 'na', { status: 'yes', note: 2 }] }, + { feature: 'Slow downloads', cells: ['yes', 'yes', 'yes', 'yes', 'yes', { status: 'na', note: 3 }] }, + { feature: 'Stuck downloading metadata', cells: ['yes', 'na', 'na', 'na', 'na', 'na'] }, + { feature: 'Pause slow checks on alt-speed limit', cells: ['yes', 'na', 'yes', 'no', 'na', 'na'] }, + { feature: 'Remove when all files skipped', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'na'] }, + { feature: 'Ignore private torrents', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'na'] }, + { feature: 'Ignored downloads', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'yes'], link: '#ignored-downloads-matching' }, + { feature: 'Change category instead of removing', cells: ['yes', 'yes', 'yes', 'yes', 'yes', { status: 'yes', note: 4 }] }, + ], + }, + { + title: 'Malware Blocker', + rows: [ + { feature: 'Block unwanted files', cells: ['yes', 'yes', 'yes', 'yes', 'yes', { status: 'partial', note: 5 }] }, + { feature: 'Remove download when files are blocked', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'yes'] }, + { feature: 'Ignore private torrents', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'na'] }, + ], + }, + { + title: 'Download Cleaner', + rows: [ + { feature: 'Seeding rules', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'na'] }, + { feature: 'Seeding rule: tag filter', cells: ['yes', 'na', 'yes', 'na', 'na', 'na'] }, + { feature: 'Seeding rule: seeders filter', cells: ['yes', 'yes', 'yes', 'yes', { status: 'no', note: 6 }, 'na'] }, + { feature: 'Dead torrents', cells: ['yes', { status: 'partial', note: 7 }, 'yes', { status: 'partial', note: 7 }, { status: 'no', note: 6 }, 'na'] }, + { feature: 'Unlinked downloads', cells: [{ status: 'yes' }, { status: 'yes', note: 8 }, { status: 'yes' }, { status: 'yes', note: 8 }, { status: 'yes', note: 8 }, 'na'] }, + { feature: 'Orphaned files', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'yes'] }, + ], + }, +]; + +export const ignoredDownloadsMatchingGroups = [ + { + rows: [ + { feature: 'Hash / job ID', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'yes'] }, + { feature: 'Category', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'yes'] }, + { feature: 'Tag', cells: ['yes', 'na', 'no', 'na', 'na', 'na'] }, + { feature: 'Tracker', cells: ['yes', 'yes', 'yes', 'yes', 'yes', 'na'] }, + ], + }, +]; + +export const capabilityNotes = { + 1: "The client has no native \"stalled\" state. It's inferred from the download speed being zero.", + 2: "Removes jobs SABnzbd moves to history as Failed, while the *arr still lists them in its queue. This covers jobs that the *arr's Failed Download Handling doesn't catch, such as NZBs added manually, grabbed by another *arr, or with purged history.", + 3: 'SABnzbd fails a job that stops progressing on its own while the *arr still lists it in its queue. The failure is then handled automatically.', + 4: "The *arr removes the item with a category change. SABnzbd's settings determine what happens to the job.", + 5: "SABnzbd can't skip files mid-download, so the Malware Blocker deletes matching files from completed jobs. The *arr can import a job before a Malware Blocker run reaches it.", + 6: 'rTorrent reports no tracker state, so no seeder count is available.', + 7: 'The client reports no tracker health, so only the seeder count matters. Unregistered torrents may remain uncaught while they have seeders.', + 8: 'The client has no tags. "Use tag" falls back to a category change.', +}; +
@@ -107,7 +176,7 @@ Only the latest versions of the following applications are supported, unless exp color: '#e90f0fff' }, { - name: 'µTorrent', + name: 'uTorrent', iconLight: useBaseUrl('/img/icons/utorrent-light.svg'), iconDark: useBaseUrl('/img/icons/utorrent-dark.svg'), url: 'https://www.utorrent.com', @@ -119,8 +188,37 @@ Only the latest versions of the following applications are supported, unless exp iconDark: useBaseUrl('/img/icons/rtorrent-dark.svg'), url: 'https://rakshasa.github.io/rtorrent/', color: '#888888' + }, + { + name: 'SABnzbd', + iconLight: useBaseUrl('/img/icons/sabnzbd-light.svg'), + iconDark: useBaseUrl('/img/icons/sabnzbd-dark.svg'), + url: 'https://sabnzbd.org', + color: '#f5c400' } ]} /> -
\ No newline at end of file +## Capabilities + +Clients expose different amounts of detail through their APIs, so some features aren't available on every client. + + + +### Ignored downloads matching + +What an ignore rule can match on, per client. + + + +
diff --git a/docs/docs/6_faq.mdx b/docs/docs/6_faq.mdx index 0814f856..b3960a87 100644 --- a/docs/docs/6_faq.mdx +++ b/docs/docs/6_faq.mdx @@ -47,20 +47,6 @@ Always test your configuration in dry run mode first, especially when setting up - - -Failed imports are handled regardless of the download client, so Usenet users can benefit from using it. - -But on a broader spectrum, Cleanuparr will not be adding native Usenet support, mainly because: -- I am not using Usenet myself, so I can't properly test or maintain it. -- People asking for Usenet support offered no help with development or testing, as seen [here](https://github.com/Cleanuparr/Cleanuparr/issues/137) and [here](https://github.com/Cleanuparr/Cleanuparr/issues/226). - - - + +or SABnzbd, set the scan directory to the **Completed Download Folder** configured under SABnzbd's Folders settings. Cleanuparr skips SABnzbd's completed and temporary download folders, so setting the scan directory to a parent folder won't clean anything. + + @@ -142,6 +137,14 @@ Password for download client authentication. + + +API key for download client authentication, for clients that use one instead of a username/password. Required for SABnzbd; found under SABnzbd's own Config > General page. + + +
diff --git a/docs/docs/configuration/queue-cleaner/index.mdx b/docs/docs/configuration/queue-cleaner/index.mdx index 06d08e12..c669f077 100644 --- a/docs/docs/configuration/queue-cleaner/index.mdx +++ b/docs/docs/configuration/queue-cleaner/index.mdx @@ -121,7 +121,7 @@ Number of strikes before a failed import download is removed from the queue. Set When enabled, private torrents will be skipped during failed import cleaning. This is useful if you want to preserve private tracker content even when imports fail. -This setting needs a download client to be configured. +This setting needs a download client of the matching protocol to be configured. @@ -149,7 +149,7 @@ Setting this to true means private torrents will be permanently deleted, potenti -This setting needs a download client to be configured. +This setting needs a download client of the matching protocol to be configured. @@ -158,10 +158,10 @@ This setting needs a download client to be configured. title="Skip If Not Found In Client" > -When enabled, torrents that are not found in any enabled torrent client will skip the failed import check. This is useful when the connection between Cleanuparr and your download client is temporarily broken or when the download client is down, preventing accidental removal of private torrents when their privacy status cannot be determined. +When enabled, a download that is not found in any enabled client of its protocol skips the failed import check. A torrent is looked up in your torrent clients, a usenet download in your usenet clients. This is useful when the connection between Cleanuparr and your download client is temporarily broken or when the download client is down, preventing accidental removal of private torrents when their privacy status cannot be determined. -This setting needs a download client to be configured. +This setting needs a download client of the matching protocol to be configured. @@ -621,4 +621,28 @@ Setting this to true means private torrents will be permanently deleted, potenti
+ +
+ +Usenet + +

+ Behaviour specific to Usenet download clients such as SABnzbd. +

+ + + +Jobs SABnzbd marks as `Failed` are removed from the *arr queue and from SABnzbd, then searched again. + + +Requires a SABnzbd download client. This is always on and is not configurable. + + + + +
+
+ \ No newline at end of file diff --git a/docs/docs/configuration/stats/index.mdx b/docs/docs/configuration/stats/index.mdx index 3db95c2b..3b727927 100644 --- a/docs/docs/configuration/stats/index.mdx +++ b/docs/docs/configuration/stats/index.mdx @@ -278,6 +278,7 @@ The raw event audit for the timeframe. The higher-level sections (`strikes`, `re - `DownloadMarkedForDeletion`: A download was flagged for removal, just before it is deleted. - `SearchTriggered`: The Seeker started a search for a missing or upgradeable item. - `StrikeReset`: A download recovered, so its accumulated strikes were cleared. + - `ForceImported`: A download the *arr had blocked from importing was force-imported instead of striked. - **bySeverity**: Events grouped by severity. Possible severities: - `Information` - `Warning` @@ -321,6 +322,7 @@ Downloads removed from the queue in the timeframe, broken down by reason. This i - `AllFilesSkippedByQBit`: Removed because qBittorrent marked every file as `do not download`, so nothing was ever downloaded. - `AllFilesBlocked`: Removed because every file matched the malware blocklist. - `AtLeastOneFileBlocked`: Removed because at least one file matched the malware blocklist. + - `DownloadFailed`: Removed because SABnzbd moved the job to history as `Failed`. The number of **Malware blocked** can be derived from the sum of the `AllFilesBlocked` and `AtLeastOneFileBlocked`. diff --git a/docs/src/components/documentation/CapabilityMatrix.tsx b/docs/src/components/documentation/CapabilityMatrix.tsx new file mode 100644 index 00000000..85237963 --- /dev/null +++ b/docs/src/components/documentation/CapabilityMatrix.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import styles from './documentation.module.css'; + +export type CapabilityStatus = 'yes' | 'partial' | 'no' | 'na'; + +export interface CapabilityCell { + status: CapabilityStatus; + note?: number; +} + +export type CapabilityCellInput = CapabilityStatus | CapabilityCell; + +export interface CapabilityRow { + feature: string; + cells: CapabilityCellInput[]; + link?: string; +} + +export interface CapabilityGroup { + title?: string; + rows: CapabilityRow[]; +} + +export interface CapabilityClient { + name: string; + icon: string; +} + +export interface CapabilityMatrixProps { + id: string; + clients: CapabilityClient[]; + groups: CapabilityGroup[]; + notes?: Record; + legend?: boolean; +} + +const STATUS_LABEL: Record = { + yes: 'Yes', + partial: 'Partial', + no: 'No', + na: 'N/A', +}; + +const STATUS_CLASS: Record = { + yes: styles.capabilityYes, + partial: styles.capabilityPartial, + no: styles.capabilityNo, + na: styles.capabilityNa, +}; + +function normalizeCell(cell: CapabilityCellInput): CapabilityCell { + return typeof cell === 'string' ? { status: cell } : cell; +} + +export default function CapabilityMatrix({ id, clients, groups, notes, legend = true }: Readonly) { + const iconBase = useBaseUrl('/img/icons/'); + const noteIds = notes + ? Object.keys(notes) + .map(Number) + .sort((a, b) => a - b) + : []; + + return ( +
+ {legend && ( +
+ Yes + Supported + Partial + Works, with caveats + No + Not supported by Cleanuparr on this client + N/A + The client has no such concept +
+ )} + +

Swipe sideways to see all clients →

+ +
+ + + + + {clients.map((client) => ( + + ))} + + + + {groups.map((group, groupIndex) => ( + + {group.title && ( + + + + )} + {group.rows.map((row, rowIndex) => ( + + + {row.cells.map((rawCell, cellIndex) => { + const cell = normalizeCell(rawCell); + const tooltipId = cell.note + ? `${id}-tooltip-${groupIndex}-${rowIndex}-${cellIndex}` + : undefined; + return ( + + ); + })} + + ))} + + ))} + +
Feature + + + + {client.name} + +
+ {group.title} +
+ {row.link ? {row.feature} : row.feature} + + + + {STATUS_LABEL[cell.status]} + + {cell.note && ( + + + {cell.note} + + + {notes?.[cell.note]} + + + )} + +
+
+ + {noteIds.length > 0 && ( +
    + {noteIds.map((noteId) => ( +
  1. + {notes![noteId]} +
  2. + ))} +
+ )} +
+ ); +} diff --git a/docs/src/components/documentation/documentation.module.css b/docs/src/components/documentation/documentation.module.css index 43a06636..f164cc55 100644 --- a/docs/src/components/documentation/documentation.module.css +++ b/docs/src/components/documentation/documentation.module.css @@ -732,6 +732,329 @@ text-decoration: underline; } +/* Capability Matrix */ +.capabilityMatrixSection { + margin: var(--doc-spacing-lg) 0; + scroll-margin-top: 100px; +} + +.capabilityLegend { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0.4rem 0.75rem; + margin: 0 0 var(--doc-spacing-md) 0; + font-size: 0.85rem; + color: var(--doc-text-secondary); +} + +.capabilityLegendText { + justify-self: start; +} + +.capabilityTableWrapper { + overflow-x: auto; + border-radius: var(--doc-radius-md); + box-shadow: var(--doc-shadow-sm); + -webkit-overflow-scrolling: touch; + background-color: var(--doc-bg-secondary); + background-image: + linear-gradient(to right, var(--doc-bg-secondary) 0, rgba(0, 0, 0, 0) 24px), + linear-gradient(to left, var(--doc-bg-secondary) 0, rgba(0, 0, 0, 0) 24px), + linear-gradient(to right, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0) 16px), + linear-gradient(to left, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0) 16px); + background-repeat: no-repeat; + background-size: 24px 100%, 24px 100%, 16px 100%, 16px 100%; + background-position: 0 0, 100% 0, 0 0, 100% 0; + background-attachment: local, local, scroll, scroll; +} + +.capabilityTable { + width: 100%; + min-width: 650px; + border-collapse: collapse; + table-layout: fixed; +} + +/* outranks .markdown table, whose overflow:hidden breaks sticky */ +.capabilityTableWrapper .capabilityTable { + overflow: visible; + margin: 0; +} + +/* outranks the .markdown table th/td padding */ +.capabilityTableWrapper .capabilityTable th, +.capabilityTableWrapper .capabilityTable td { + padding: 0.5rem 0.15rem; + border-bottom: 1px solid var(--doc-border-color); + text-align: left; +} + +.capabilityTable .capabilityClientHeader { + width: calc((100% - 200px) / 6); + background: var(--doc-bg-primary); + color: var(--doc-text-primary); + text-align: center; + text-transform: none; + vertical-align: bottom; + font-weight: 600; + font-size: 0.72rem; + border-bottom: 2px solid var(--doc-border-hover); +} + +.capabilityClientHeaderInner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; +} + +.capabilityClientIcon { + width: 24px; + height: 24px; + object-fit: contain; +} + +.capabilityClientName { + white-space: nowrap; +} + +.capabilityTableWrapper .capabilityTable .capabilityCornerCell { + width: 200px; + padding: 0.5rem 0.75rem; + background: var(--doc-bg-primary); + color: var(--doc-text-primary); + text-transform: none; + position: sticky; + left: 0; + z-index: 12; + border-bottom: 2px solid var(--doc-border-hover); +} + +.capabilityGroupHeader { + background: var(--doc-bg-primary); + color: var(--doc-text-secondary); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.capabilityTableWrapper .capabilityTable .capabilityGroupHeader { + padding-left: 0; +} + +/* padding lives here because sticky left:0 ignores the th's padding */ +.capabilityGroupHeaderLabel { + display: inline-block; + position: sticky; + left: 0; + z-index: 11; + padding: 0 0.75rem 0 0.75rem; +} + +.capabilityTableWrapper .capabilityTable .capabilityFeatureCell { + position: sticky; + left: 0; + z-index: 11; + background: var(--doc-bg-secondary); + font-weight: 500; + text-transform: none; + white-space: normal; + min-width: 200px; + padding: 0.5rem 0.75rem; +} + +.capabilityTableWrapper .capabilityTable .capabilityCell { + text-align: center; +} + +.capabilityCellContent { + display: inline-flex; + align-items: center; + gap: 0; + white-space: nowrap; +} + +.capabilityPill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem 0.22rem; + border-radius: 50px; + font-size: 0.58rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0; + white-space: nowrap; +} + +.capabilityYes { + background: rgba(40, 167, 69, 0.15); + color: #14591f; +} + +.capabilityPartial { + background: rgba(255, 193, 7, 0.22); + color: #5c4300; +} + +.capabilityNo { + background: rgba(220, 53, 69, 0.15); + color: #a71d2a; +} + +.capabilityNa { + background: rgba(128, 128, 128, 0.18); + color: #595959; +} + +[data-theme='dark'] .capabilityYes { + background: rgba(40, 167, 69, 0.25); + color: #4ade80; +} + +[data-theme='dark'] .capabilityPartial { + background: rgba(255, 193, 7, 0.25); + color: #ffd84d; +} + +[data-theme='dark'] .capabilityNo { + background: rgba(220, 53, 69, 0.28); + color: #ff8a93; +} + +[data-theme='dark'] .capabilityNa { + background: rgba(160, 160, 160, 0.22); + color: #c2c2c2; +} + +/* stays below the sticky cells while scrolled */ +.capabilityNoteWrap { + position: relative; + z-index: 1; + display: inline-block; + vertical-align: middle; +} + +.capabilityNoteMarker { + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: var(--doc-border-color); + color: var(--doc-text-primary); + font-size: 0.55rem; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +.capabilityNoteMarker:hover, +.capabilityNoteMarker:focus-visible { + background: var(--doc-primary); + color: white; + text-decoration: none; + outline: 2px solid var(--doc-primary-light); + outline-offset: 2px; +} + +.capabilityTooltip { + position: absolute; + bottom: 140%; + left: 50%; + transform: translateX(-50%) translateY(4px); + width: 0; + overflow: hidden; + background: var(--doc-text-primary); + color: var(--doc-bg-primary); + padding: 0; + border-radius: var(--doc-radius-sm); + font-size: 0.8rem; + font-weight: 400; + line-height: 1.4; + text-transform: none; + letter-spacing: normal; + white-space: normal; + text-align: left; + box-shadow: var(--doc-shadow-md); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: 20; +} + +/* hidden tooltips stay zero-width so they don't widen the scroll area */ +.capabilityNoteWrap:hover .capabilityTooltip, +.capabilityNoteMarker:focus-visible + .capabilityTooltip { + width: 220px; + overflow: visible; + padding: 0.5rem 0.75rem; + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.capabilityNotesList { + margin: var(--doc-spacing-md) 0 0 0; + padding-left: 1.5rem; + color: var(--doc-text-secondary); + font-size: 0.9rem; + line-height: 1.6; +} + +.capabilityNotesList li { + margin: 0.4rem 0; + scroll-margin-top: 100px; +} + +.capabilityNotesList li:target { + color: var(--doc-text-primary); + font-weight: 600; +} + +.capabilityScrollHint { + display: none; + margin: 0 0 0.5rem 0; + font-size: 0.8rem; + color: var(--doc-text-secondary); +} + +@media (max-width: 768px) { + .capabilityLegend { + font-size: 0.8rem; + } + + .capabilityNoteWrap:hover .capabilityTooltip, + .capabilityNoteMarker:focus-visible + .capabilityTooltip { + width: 180px; + } + + .capabilityScrollHint { + display: block; + } + + .capabilityTableWrapper .capabilityTable .capabilityCornerCell { + width: 130px; + padding: 0.5rem 0.6rem; + } + + .capabilityTableWrapper .capabilityTable .capabilityFeatureCell { + min-width: 130px; + padding: 0.5rem 0.6rem; + } + + .capabilityGroupHeaderLabel { + padding: 0 0.6rem 0 0.6rem; + } + + .capabilityTable .capabilityClientHeader { + width: calc((100% - 130px) / 6); + } +} + /* Enhanced Admonitions */ .enhancedAdmonition { margin: 0.75rem 0; diff --git a/docs/src/components/documentation/index.ts b/docs/src/components/documentation/index.ts index d52d210e..3b154972 100644 --- a/docs/src/components/documentation/index.ts +++ b/docs/src/components/documentation/index.ts @@ -9,6 +9,7 @@ export { default as QuickNav } from './QuickNav'; export { default as EnhancedAdmonition, Note, Important, Warning, EnhancedNote, EnhancedImportant, EnhancedWarning } from './EnhancedAdmonition'; export { default as ElementNavigator } from './ElementNavigator'; export { default as AppCard } from './AppCard'; +export { default as CapabilityMatrix } from './CapabilityMatrix'; export { ScreenshotGallery } from './ScreenshotGallery'; export { ClickableImage } from './ClickableImage'; diff --git a/docs/static/img/icons/sabnzbd-dark.svg b/docs/static/img/icons/sabnzbd-dark.svg new file mode 100644 index 00000000..bfcb73b9 --- /dev/null +++ b/docs/static/img/icons/sabnzbd-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/img/icons/sabnzbd-light.svg b/docs/static/img/icons/sabnzbd-light.svg new file mode 100644 index 00000000..f7742044 --- /dev/null +++ b/docs/static/img/icons/sabnzbd-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/Makefile b/e2e/Makefile index b3bb417b..53ef6c79 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -12,9 +12,9 @@ WIREMOCKS = wiremock-arr wiremock-dlc wiremock-notify wiremock-blocklist APP_SERVICES = app nginx CORE_STACK = keycloak $(WIREMOCKS) -CLIENTS_STACK = $(CORE_STACK) tracker qbittorrent transmission deluge utorrent rutorrent +CLIENTS_STACK = $(CORE_STACK) tracker qbittorrent transmission deluge utorrent rutorrent sabnews sabnzbd ARR_STACK = $(CORE_STACK) wiremock-indexer tracker qbittorrent sonarr radarr -LAZYLIBRARIAN_STACK = $(CORE_STACK) wiremock-indexer tracker qbittorrent lazylibrarian +LAZYLIBRARIAN_STACK = $(CORE_STACK) wiremock-indexer tracker qbittorrent sabnews sabnzbd lazylibrarian setup: bash ./scripts/setup-test-data.sh diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 54ee0958..7002699d 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -273,6 +273,41 @@ services: retries: 12 start_period: 30s + # Fake NNTP news server (SABnzbd's own tests/sabnews.py). Serves articles from + # files under test-data/sabnews-articles and also generates NZBs for them. + sabnews: + build: + context: ./sabnews + network_mode: host + volumes: + - ./test-data/sabnews-articles:/data + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import socket; socket.create_connection(('127.0.0.1', 8888), 2)\""] + interval: 5s + timeout: 3s + retries: 12 + start_period: 10s + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:5.1.3-ls275 + network_mode: host + environment: + PUID: "1000" + PGID: "1000" + TZ: "UTC" + volumes: + - ./test-data/sabnzbd-config:/config + - ./test-data/downloads/sabnzbd:/downloads + depends_on: + sabnews: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O- 'http://127.0.0.1:8070/api?mode=version' > /dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 30s + rutorrent: image: lscr.io/linuxserver/rutorrent:latest ports: diff --git a/e2e/sabnews/Dockerfile b/e2e/sabnews/Dockerfile new file mode 100644 index 00000000..c88a0317 --- /dev/null +++ b/e2e/sabnews/Dockerfile @@ -0,0 +1,21 @@ +# Fake NNTP news server used by the e2e SABnzbd tests. +# +# tests/sabnews.py lives in the SABnzbd repo itself, not here, so this image +# fetches it from a pinned commit at build time instead of vendoring a copy. +# Bump SABNZBD_COMMIT to pick up an upstream change. +FROM python:3.12-slim + +ARG SABNZBD_COMMIT=a647f73426cb70ad60ae7964b96c2d5e939b4727 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir sabctools \ + && curl -fsSL "https://raw.githubusercontent.com/sabnzbd/sabnzbd/${SABNZBD_COMMIT}/tests/sabnews.py" -o /sabnews.py \ + && apt-get purge -y curl + +WORKDIR /data +EXPOSE 8888 + +ENTRYPOINT ["python3", "/sabnews.py"] +CMD ["-s", "0.0.0.0", "-p", "8888"] diff --git a/e2e/scripts/setup-test-data.sh b/e2e/scripts/setup-test-data.sh index f0e8e696..c03447a8 100644 --- a/e2e/scripts/setup-test-data.sh +++ b/e2e/scripts/setup-test-data.sh @@ -28,12 +28,16 @@ mkdir -p \ "$TEST_DATA/downloads/deluge" \ "$TEST_DATA/downloads/utorrent" \ "$TEST_DATA/downloads/rtorrent" \ + "$TEST_DATA/downloads/sabnzbd/incomplete" \ + "$TEST_DATA/downloads/sabnzbd/complete" \ + "$TEST_DATA/sabnews-articles" \ "$TEST_DATA/orphaned-xdev" \ "$TEST_DATA/qbittorrent-config/qBittorrent" \ "$TEST_DATA/transmission-config" \ "$TEST_DATA/deluge-config" \ "$TEST_DATA/utorrent-config" \ - "$TEST_DATA/rutorrent-config" + "$TEST_DATA/rutorrent-config" \ + "$TEST_DATA/sabnzbd-config" chmod -R a+rwX "$TEST_DATA" 2>/dev/null || true @@ -55,6 +59,46 @@ WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZ366igo9pHIQ==:Bn3qWLqOY3qE6Z+sCx2No Downloads\SavePath=/downloads EOF +# Headless SABnzbd config: a config file present (with a server already +# defined) skips the first-run wizard. host/download_dir/complete_dir are +# absolute so SabnzbdService.GetClaimedPathsAsync can resolve and claim them +# (it skips a relative download_dir as unreachable from this host). +cat > "$TEST_DATA/sabnzbd-config/sabnzbd.ini" <<'EOF' +[misc] +config_version = 25 +api_key = 0000000000000000000000000000e2e5 +nzb_key = 0000000000000000000000000000e2e5 +host = 0.0.0.0 +port = 8070 +download_dir = /downloads/incomplete +complete_dir = /downloads/complete +fail_hopeless_jobs = 1 +enable_https = 0 +check_new_rel = 0 +auto_browser = 0 +language = en +host_whitelist = localhost,127.0.0.1 +inet_exposure = 0 + +[servers] +[[sabnews]] +displayname = sabnews +host = localhost +port = 8888 +connections = 1 +ssl = 0 +enable = 1 +priority = 0 +# A connection to sabnews can stall for the full timeout; 20 is the lowest SABnzbd accepts. +timeout = 20 + +[categories] +[[*]] +pp = 3 +script = Default +priority = 0 +EOF + # Restore Sonarr and Radarr from the committed seed. # The arrs write to /config on every start, so the run gets a throwaway copy. for arr in sonarr radarr; do diff --git a/e2e/tests/_setup/live-lazylibrarian.setup.ts b/e2e/tests/_setup/live-lazylibrarian.setup.ts index 4d45fd12..8de0754a 100644 --- a/e2e/tests/_setup/live-lazylibrarian.setup.ts +++ b/e2e/tests/_setup/live-lazylibrarian.setup.ts @@ -1,10 +1,14 @@ import { test as setup } from '@playwright/test'; import { restartAppAndWait } from '../helpers/test-lifecycle'; import { indexerMock, liveLazyLibrarian, qbittorrent } from '../helpers/live-lazylibrarian'; +import { SabnzbdDriver } from '../helpers/usenet-clients/sabnzbd'; + +const sabnzbd = new SabnzbdDriver(); setup('reset app for live-lazylibrarian specs', async () => { - await Promise.all([indexerMock.waitReady(), liveLazyLibrarian.waitReady(), qbittorrent.ready()]); + await Promise.all([indexerMock.waitReady(), liveLazyLibrarian.waitReady(), qbittorrent.ready(), sabnzbd.ready()]); await liveLazyLibrarian.clearHistory(); await qbittorrent.clearAllTorrents(); + await sabnzbd.clearAll(); await restartAppAndWait(); }); diff --git a/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts b/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts index 5af7cda6..5b0eda08 100644 --- a/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts @@ -246,7 +246,6 @@ test.describe.serial('Dead torrent cleanup', () => { enabled: true, name: `${s.key} dead e2e`, typeName: s.driver.typeName, - type: 'Torrent', host: s.driver.cleanuparrHost, username: s.driver.username ?? '', password: s.driver.password ?? '', diff --git a/e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts b/e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts index c85a15a0..b2eb35d5 100644 --- a/e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts +++ b/e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts @@ -20,7 +20,6 @@ test.describe.serial('Dead Torrent Config API', () => { enabled: false, name: 'e2e-dead-qbit', typeName: 'qBittorrent', - type: 'Torrent', host: 'http://localhost:9999', }); expect(res.status).toBe(201); @@ -30,7 +29,6 @@ test.describe.serial('Dead Torrent Config API', () => { enabled: false, name: 'e2e-dead-rtorrent', typeName: 'rTorrent', - type: 'Torrent', host: 'http://localhost:9998', }); expect(rt.status).toBe(201); diff --git a/e2e/tests/download-cleaner/deluge-large-torrent.spec.ts b/e2e/tests/download-cleaner/deluge-large-torrent.spec.ts index 62f087ac..c99584c5 100644 --- a/e2e/tests/download-cleaner/deluge-large-torrent.spec.ts +++ b/e2e/tests/download-cleaner/deluge-large-torrent.spec.ts @@ -123,7 +123,6 @@ test.describe.serial('Deluge unlinked cleanup with a large torrent', () => { enabled: true, name: 'Deluge large torrent e2e', typeName: deluge.typeName, - type: 'Torrent', host: deluge.cleanuparrHost, username: deluge.username, password: deluge.password, diff --git a/e2e/tests/download-cleaner/deluge-reconnect.spec.ts b/e2e/tests/download-cleaner/deluge-reconnect.spec.ts index be6b14e4..aa5237eb 100644 --- a/e2e/tests/download-cleaner/deluge-reconnect.spec.ts +++ b/e2e/tests/download-cleaner/deluge-reconnect.spec.ts @@ -9,7 +9,6 @@ function payload(): Record { enabled: true, name: 'Deluge reconnect e2e', typeName: deluge.typeName, - type: 'Torrent', host: deluge.cleanuparrHost, username: deluge.username, password: deluge.password, diff --git a/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts b/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts index c4b69f70..6553a8d6 100644 --- a/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts @@ -147,7 +147,6 @@ test.describe.serial('Deluge seeding rule cleanup', () => { enabled: true, name: 'Deluge seeding rule e2e', typeName: deluge.typeName, - type: 'Torrent', host: deluge.cleanuparrHost, username: deluge.username, password: deluge.password, diff --git a/e2e/tests/download-cleaner/download-cleaner-ui.spec.ts b/e2e/tests/download-cleaner/download-cleaner-ui.spec.ts index 62fcce26..4a07ae77 100644 --- a/e2e/tests/download-cleaner/download-cleaner-ui.spec.ts +++ b/e2e/tests/download-cleaner/download-cleaner-ui.spec.ts @@ -20,12 +20,12 @@ import { const CLIENT_A = 'E2E DC One'; const CLIENT_B = 'E2E DC Two'; const CLIENT_TRANSMISSION = 'E2E DC Transmission'; +const CLIENT_SABNZBD = 'E2E DC SABnzbd'; function clientPayload(name: string) { return { enabled: true, name, - type: 'Torrent', typeName: 'qBittorrent', host: 'http://localhost:8090', username: 'admin', @@ -46,6 +46,19 @@ function transmissionClientPayload(name: string) { }; } +function sabnzbdClientPayload(name: string) { + return { + enabled: true, + name, + typeName: 'Sabnzbd', + host: 'http://localhost:8070', + apiKey: 'e2e-ui-key', + urlBase: '', + downloadDirectorySource: null, + downloadDirectoryTarget: null, + }; +} + // Behavior-parity spec for the Download Cleaner form: global config, seeding-rule modal, // and the per-client sub-configs (the linkedSignal-backed part with the highest migration risk). test.describe.serial('Download Cleaner UI', () => { @@ -56,9 +69,10 @@ test.describe.serial('Download Cleaner UI', () => { await createDownloadClient(token, clientPayload(CLIENT_A)); await createDownloadClient(token, clientPayload(CLIENT_B)); await createDownloadClient(token, transmissionClientPayload(CLIENT_TRANSMISSION)); + await createDownloadClient(token, sabnzbdClientPayload(CLIENT_SABNZBD)); const clients = await listDownloadClients(token); for (const c of clients) { - if (c.name === CLIENT_A || c.name === CLIENT_B || c.name === CLIENT_TRANSMISSION) createdIds.push(c.id); + if ([CLIENT_A, CLIENT_B, CLIENT_TRANSMISSION, CLIENT_SABNZBD].includes(c.name)) createdIds.push(c.id); } }); @@ -204,4 +218,25 @@ test.describe.serial('Download Cleaner UI', () => { await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(dialog).toBeHidden(); }); + + test('SABnzbd hides seeding rules, unlinked downloads and dead torrents, but keeps orphaned files', async ({ page }) => { + await enableAndSelect(page, CLIENT_SABNZBD); + + await expect(page.locator('app-accordion').filter({ hasText: 'Seeding Rules' })).toHaveCount(0); + await expect(page.locator('app-accordion').filter({ hasText: 'Unlinked Downloads' })).toHaveCount(0); + await expect(page.locator('app-accordion').filter({ hasText: 'Dead Torrents' })).toHaveCount(0); + await expect(page.locator('app-accordion').filter({ hasText: 'Orphaned Files' })).toBeVisible(); + }); + + test('a failed types load shows the error state and recovers on retry', async ({ page }) => { + await page.route('**/download_client/types', (route) => route.abort()); + await loginAndGotoSettings(page, 'download-cleaner'); + + await expect(page.getByRole('heading', { name: 'Could not connect to server' })).toBeVisible(); + + await page.unroute('**/download_client/types'); + await page.getByRole('button', { name: 'Retry' }).click(); + + await expect(toggle(page, 'Enabled')).toBeVisible(); + }); }); diff --git a/e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts b/e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts index 89b44edc..7c947a2e 100644 --- a/e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts @@ -146,7 +146,6 @@ test.describe.serial('Orphaned files cleanup — behaviors', () => { enabled: true, name: 'qBittorrent behaviors', typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', diff --git a/e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts b/e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts index e3dcc76a..de664091 100644 --- a/e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts @@ -181,7 +181,6 @@ function runClientScenario(fixture: TorrentClientFixture, getToken: () => string enabled: true, name: `${driver.typeName} e2e`, typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', diff --git a/e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts b/e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts index 538230ef..4aa72464 100644 --- a/e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts @@ -91,7 +91,6 @@ test.describe.serial('Orphaned files cleanup — content path vs display name', enabled: true, name: 'qBittorrent content-path', typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', diff --git a/e2e/tests/download-cleaner/orphaned-files-cross-device.spec.ts b/e2e/tests/download-cleaner/orphaned-files-cross-device.spec.ts index d61ec080..ddfe5a7f 100644 --- a/e2e/tests/download-cleaner/orphaned-files-cross-device.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-cross-device.spec.ts @@ -107,7 +107,6 @@ test.describe.serial('Orphaned files cleanup — cross-device move', () => { enabled: true, name: 'qBittorrent cross-device', typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', diff --git a/e2e/tests/download-cleaner/orphaned-files-empty-client.spec.ts b/e2e/tests/download-cleaner/orphaned-files-empty-client.spec.ts index dfec4258..67bf95a1 100644 --- a/e2e/tests/download-cleaner/orphaned-files-empty-client.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-empty-client.spec.ts @@ -125,7 +125,6 @@ test.describe.serial('Orphaned files cleanup — client with no torrents', () => enabled: true, name: 'qBittorrent empty', typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', @@ -225,7 +224,6 @@ test.describe.serial('Orphaned files cleanup — client with no torrents', () => enabled: true, name: 'qBittorrent unreachable sibling', typeName: 'qBittorrent', - type: 'Torrent', // Nothing listens on port 1, so the qBit client fails to connect. host: 'http://127.0.0.1:1', username: 'admin', diff --git a/e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts b/e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts index 68d1c8b5..26dac675 100644 --- a/e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts @@ -98,7 +98,6 @@ test.describe.serial('Orphaned files cleanup — refuses to scan when client dat enabled: true, name: 'qBittorrent unreachable', typeName: 'qBittorrent', - type: 'Torrent', // Nothing listens on port 1, so the qBit client fails to connect. host: 'http://127.0.0.1:1', username: 'admin', diff --git a/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts b/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts index 6283501f..d3198676 100644 --- a/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts +++ b/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts @@ -110,7 +110,6 @@ test.describe.serial('rTorrent seeding rule tracker pattern cleanup', () => { enabled: true, name: 'rTorrent seeding rule e2e', typeName: rtorrent.typeName, - type: 'Torrent', host: rtorrent.cleanuparrHost, username: rtorrent.username, password: rtorrent.password, diff --git a/e2e/tests/download-cleaner/sabnzbd-orphaned-files-broken-response.spec.ts b/e2e/tests/download-cleaner/sabnzbd-orphaned-files-broken-response.spec.ts new file mode 100644 index 00000000..3cda6734 --- /dev/null +++ b/e2e/tests/download-cleaner/sabnzbd-orphaned-files-broken-response.spec.ts @@ -0,0 +1,219 @@ +import { test, expect } from '@playwright/test'; +import { existsSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + listDownloadClients, + deleteDownloadClient, + updateDownloadCleanerConfig, + getDownloadCleanerConfig, + updateOrphanedFilesConfig, + triggerJob, +} from '../helpers/app-api'; +import { WireMockClient, DownloadClientStubs } from '../helpers/mocks'; +import { TEST_CONFIG } from '../helpers/test-config'; +import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent'; +import { resetDirectory } from '../helpers/torrent-fixtures'; +import { mkdirShared, writeFileShared } from '../helpers/shared-volume'; + +/** + * Regression guard for a class of bugs where SABnzbd answers with a broken + * response and the cleaner reads it as an empty/idle client instead of an + * unreachable one: + * - a `{}` history body (version drift, a proxy rewrite) used to read as + * zero jobs, so the orphan scan moved every completed job folder; + * - a busy queue whose `get_config` call fails or returns an unusable + * `download_dir` used to claim nothing from the incomplete dir instead of + * failing the client, so the scan could move an in-progress download. + * + * All three cases are WireMock-backed: there is no live-container SAB leg for + * "SAB answers with garbage", so these responses have to be stubbed. A qBittorrent + * sibling client's sentinel move marks each scan as finished. + */ + +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); +const SLUG = 'sab-broken-response'; +const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG); +const HOST_ORPHANED_DIR = join(HOST_DOWNLOADS, SLUG, 'orphaned'); +const APP_SCAN_DIR = `/e2e-downloads/${SLUG}`; +const APP_ORPHANED_DIR = `/e2e-downloads/${SLUG}/orphaned`; + +const SIBLING_SLUG = 'sab-broken-response-qbit'; +const HOST_SIBLING_SCAN_DIR = join(HOST_DOWNLOADS, SIBLING_SLUG); +const HOST_SIBLING_ORPHANED_DIR = join(HOST_DOWNLOADS, SIBLING_SLUG, 'orphaned'); +const APP_SIBLING_SCAN_DIR = `/e2e-downloads/${SIBLING_SLUG}`; +const APP_SIBLING_ORPHANED_DIR = `/e2e-downloads/${SIBLING_SLUG}/orphaned`; + +function writeOrphanFile(dir: string, name: string): string { + mkdirShared(dir); + const path = join(dir, name); + writeFileShared(path, 'orphan'); + return path; +} + +async function triggerAndWaitForSentinel(token: string): Promise { + const sentinel = `sentinel-${Date.now().toString(36)}.mkv`; + writeOrphanFile(HOST_SIBLING_SCAN_DIR, sentinel); + + const res = await triggerJob(token, 'DownloadCleaner'); + expect(res.ok, `triggerJob: ${res.status}`).toBe(true); + + await expect + .poll(() => existsSync(join(HOST_SIBLING_ORPHANED_DIR, sentinel)), { timeout: 30_000 }) + .toBe(true); +} + +async function triggerAndSettle(token: string): Promise { + await triggerAndWaitForSentinel(token); + // Configs scan in no fixed order, so SAB may still be moving after the sibling sentinel. + // Jobs never overlap, so a second run's sentinel proves the first run finished. + await triggerAndWaitForSentinel(token); +} + +test.describe.serial('Orphaned files cleanup: SABnzbd broken responses', () => { + const downloadClientMock = new WireMockClient(TEST_CONFIG.mocks.downloadClientAdminUrl); + let token: string; + let clientId: string; + let siblingId: string; + + test.beforeAll(async () => { + test.setTimeout(90_000); + token = await loginAndGetToken(); + await downloadClientMock.waitReady(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + const createRes = await createDownloadClient(token, { + enabled: true, + name: 'SABnzbd broken-response e2e', + type: 'Usenet', + typeName: 'Sabnzbd', + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: 'e2e-key', + downloadDirectorySource: '/downloads', + downloadDirectoryTarget: APP_SCAN_DIR, + }); + expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeLessThan(300); + clientId = (await createRes.json()).id; + + const dcCurrent = await (await getDownloadCleanerConfig(token)).json(); + await updateDownloadCleanerConfig(token, { + enabled: true, + cronExpression: dcCurrent.cronExpression || '0 0 * * * ?', + useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false, + ignoredDownloads: [], + }); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [APP_SCAN_DIR], + orphanedDirectory: APP_ORPHANED_DIR, + excludePatterns: [], + minFileAgeHours: 0, + purgeAfterHours: null, + }); + expect(ofc.status).toBe(200); + + const qbitDriver = new QBittorrentDriver(); + await qbitDriver.ready(); + await qbitDriver.clearAllTorrents(); + + const siblingRes = await createDownloadClient(token, { + enabled: true, + name: 'qBittorrent sibling for sab-broken-response', + typeName: qbitDriver.typeName, + type: 'Torrent', + host: qbitDriver.cleanuparrHost, + username: qbitDriver.username ?? '', + password: qbitDriver.password ?? '', + downloadDirectorySource: '/downloads', + downloadDirectoryTarget: APP_SIBLING_SCAN_DIR, + }); + expect(siblingRes.status).toBeLessThan(300); + const sibling = await siblingRes.json(); + siblingId = sibling.id; + + const siblingOfc = await updateOrphanedFilesConfig(token, siblingId, { + enabled: true, + scanDirectories: [APP_SIBLING_SCAN_DIR], + orphanedDirectory: APP_SIBLING_ORPHANED_DIR, + excludePatterns: [], + minFileAgeHours: 0, + purgeAfterHours: null, + }); + expect(siblingOfc.status).toBe(200); + }); + + test.afterAll(async () => { + if (clientId) { + await deleteDownloadClient(token, clientId).catch(() => undefined); + } + if (siblingId) { + await deleteDownloadClient(token, siblingId).catch(() => undefined); + } + }); + + test.beforeEach(async () => { + resetDirectory(HOST_SCAN_DIR); + mkdirShared(HOST_ORPHANED_DIR); + resetDirectory(HOST_SIBLING_SCAN_DIR); + mkdirShared(HOST_SIBLING_ORPHANED_DIR); + await downloadClientMock.resetAll(); + }); + + test('a {} history body leaves the scan dir untouched; a valid empty history still moves it', async () => { + const orphan = writeOrphanFile(HOST_SCAN_DIR, 'orphan.mkv'); + + await downloadClientMock.stub(DownloadClientStubs.sabQueueStub([])); + await downloadClientMock.stub(DownloadClientStubs.sabEmptyBodyStub('history')); + + await triggerAndSettle(token); + + expect(existsSync(orphan), 'a broken history body must not be read as an empty client').toBe(true); + expect(readdirSync(HOST_ORPHANED_DIR)).toHaveLength(0); + + // Positive control: a valid empty history still proves the scan actually ran. + await downloadClientMock.resetAll(); + await downloadClientMock.stub(DownloadClientStubs.sabQueueStub([])); + await downloadClientMock.stub(DownloadClientStubs.sabHistoryStub([])); + await downloadClientMock.stub(DownloadClientStubs.sabGetConfigStub('/downloads/incomplete')); + + await triggerAndSettle(token); + + expect(existsSync(orphan)).toBe(false); + expect(readdirSync(HOST_ORPHANED_DIR)).toContain('orphan.mkv'); + }); + + test('a busy queue with a relative download_dir leaves the scan dir untouched', async () => { + const orphan = writeOrphanFile(HOST_SCAN_DIR, 'orphan.mkv'); + + await downloadClientMock.stub(DownloadClientStubs.sabQueueStub([{ nzo_id: 'SAB-BUSY-1', status: 'Downloading' }])); + await downloadClientMock.stub(DownloadClientStubs.sabHistoryStub([])); + await downloadClientMock.stub(DownloadClientStubs.sabGetConfigStub('Downloads/incomplete')); + + await triggerAndSettle(token); + + expect(existsSync(orphan), 'a relative download_dir must fail the client, not claim nothing').toBe(true); + expect(readdirSync(HOST_ORPHANED_DIR)).toHaveLength(0); + }); + + test('a busy queue with a failing get_config leaves the SAB dir untouched while a sibling client is still cleaned', async () => { + const sabOrphan = writeOrphanFile(HOST_SCAN_DIR, 'orphan.mkv'); + const siblingOrphan = writeOrphanFile(HOST_SIBLING_SCAN_DIR, 'orphan.mkv'); + + await downloadClientMock.stub(DownloadClientStubs.sabQueueStub([{ nzo_id: 'SAB-BUSY-2', status: 'Downloading' }])); + await downloadClientMock.stub(DownloadClientStubs.sabHistoryStub([])); + await downloadClientMock.stub(DownloadClientStubs.sabGetConfigErrorStub()); + + await triggerAndSettle(token); + + expect(existsSync(sabOrphan), 'a failing get_config must fail the SAB client, not claim nothing').toBe(true); + expect(readdirSync(HOST_ORPHANED_DIR)).toHaveLength(0); + + expect(existsSync(siblingOrphan), 'a sibling client must still be scanned when SAB fails').toBe(false); + expect(readdirSync(HOST_SIBLING_ORPHANED_DIR)).toContain('orphan.mkv'); + }); +}); diff --git a/e2e/tests/download-cleaner/sabnzbd-orphaned-files.spec.ts b/e2e/tests/download-cleaner/sabnzbd-orphaned-files.spec.ts new file mode 100644 index 00000000..82d4d94b --- /dev/null +++ b/e2e/tests/download-cleaner/sabnzbd-orphaned-files.spec.ts @@ -0,0 +1,343 @@ +import { test, expect } from '@playwright/test'; +import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + deleteDownloadClient, + listDownloadClients, + getDownloadCleanerConfig, + updateDownloadCleanerConfig, + updateOrphanedFilesConfig, + triggerJob, +} from '../helpers/app-api'; +import { SabnzbdDriver } from '../helpers/usenet-clients/sabnzbd'; +import { mkdirShared } from '../helpers/shared-volume'; + +/** + * SABnzbd only has the OrphanClaims capability for DownloadCleaner: no seeding + * rules, unlinked downloads or dead-torrent detection (usenet jobs are moved by + * the arr on import, never hardlinked, so "unlinked" is meaningless for them; + * `DownloadClientCapabilityConsistencyTests` on the backend pins this). This spec + * covers the one capability it does have: claiming its own paths so orphaned-files + * cleanup doesn't delete a job it's still using. + */ + +const HOST_DOWNLOADS = join(__dirname, '..', '..', 'test-data', 'downloads', 'sabnzbd'); +const APP_DOWNLOADS = '/e2e-downloads/sabnzbd'; + +function resetDir(path: string): void { + rmSync(path, { recursive: true, force: true }); + mkdirShared(path); +} + +async function waitForMove(dir: string, name: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (existsSync(join(dir, name))) { + return true; + } + await new Promise((r) => setTimeout(r, 1000)); + } + return false; +} + +test.describe.serial('SABnzbd live: orphaned files claims', () => { + const driver = new SabnzbdDriver(); + let token: string; + let clientId: string; + + test.beforeAll(async () => { + test.setTimeout(60_000); + token = await loginAndGetToken(); + await driver.ready(); + await driver.clearAll(); + await driver.resumeQueue(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + const createRes = await createDownloadClient(token, { + enabled: true, + name: 'SABnzbd orphaned-files e2e', + typeName: 'Sabnzbd', + host: driver.cleanuparrHost, + apiKey: driver.apiKey, + downloadDirectorySource: '/downloads', + downloadDirectoryTarget: APP_DOWNLOADS, + }); + expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeLessThan(300); + clientId = (await createRes.json()).id; + + const dc = await (await getDownloadCleanerConfig(token)).json(); + await updateDownloadCleanerConfig(token, { + enabled: true, + cronExpression: dc.cronExpression || '0 0 * * * ?', + useAdvancedScheduling: dc.useAdvancedScheduling ?? false, + ignoredDownloads: [], + }); + }); + + test.afterAll(async () => { + if (clientId) { + await deleteDownloadClient(token, clientId).catch(() => undefined); + } + await driver.clearAll().catch(() => undefined); + await driver.resumeQueue().catch(() => undefined); + }); + + test('a completed job storage folder survives; an unrelated folder is moved out', async () => { + test.setTimeout(90_000); + const completeDir = join(HOST_DOWNLOADS, 'complete'); + resetDir(completeDir); + const orphanedDir = join(HOST_DOWNLOADS, 'complete-orphaned'); + resetDir(orphanedDir); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [`${APP_DOWNLOADS}/complete`], + orphanedDirectory: `${APP_DOWNLOADS}/complete-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + const dirName = `claim-${Date.now().toString(36)}`; + // Two files, not one: SABnzbd reports a single-file job's `storage` as the file + // path itself rather than its folder, which GetClaimedPathsAsync does not expect. + driver.writeArticleFile(dirName, 'data1.bin', 32_768); + driver.writeArticleFile(dirName, 'data2.bin', 32_768); + const nzoId = await driver.addWorkingNzbDir(dirName, 'e2e-ofc'); + await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + // A loose folder with no SABnzbd job behind it at all. + mkdirSync(join(completeDir, 'leftover-unrelated'), { recursive: true }); + + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-unrelated'), 'unrelated folder should have been moved out').toBe(true); + expect(existsSync(join(completeDir, dirName)), 'the completed job folder must survive: it is claimed').toBe(true); + }); + + test('a single-file job survives even though SABnzbd reports storage as the file, not its folder', async () => { + test.setTimeout(90_000); + const completeDir = join(HOST_DOWNLOADS, 'complete'); + resetDir(completeDir); + const orphanedDir = join(HOST_DOWNLOADS, 'complete-orphaned'); + resetDir(orphanedDir); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [`${APP_DOWNLOADS}/complete`], + orphanedDirectory: `${APP_DOWNLOADS}/complete-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + const fileName = `single-${Date.now().toString(36)}.bin`; + const nzoId = await driver.addWorkingNzb(fileName, 32_768, 'e2e-ofc'); + const history = await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + // SABnzbd's storage for a single-file job names the file inside its job folder, + // not the folder itself: the bug GetClaimedPathsAsync must work around. + expect(history.storage, 'the live quirk this test guards against').toMatch(/\.bin$/); + + mkdirSync(join(completeDir, 'leftover-unrelated'), { recursive: true }); + + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-unrelated'), 'unrelated folder should have been moved out').toBe(true); + expect(existsSync(join(completeDir, history.name)), 'the completed job folder must survive: it is claimed').toBe(true); + }); + + test('a busy queue protects every top-level entry under the incomplete dir', async () => { + test.setTimeout(90_000); + const incompleteDir = join(HOST_DOWNLOADS, 'incomplete'); + resetDir(incompleteDir); + const orphanedDir = join(HOST_DOWNLOADS, 'incomplete-orphaned'); + resetDir(orphanedDir); + const sentinelDir = join(HOST_DOWNLOADS, 'busy-sentinel'); + resetDir(sentinelDir); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [`${APP_DOWNLOADS}/incomplete`, `${APP_DOWNLOADS}/busy-sentinel`], + orphanedDirectory: `${APP_DOWNLOADS}/incomplete-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + // A leftover with no live job, placed directly under the incomplete dir. + mkdirSync(join(incompleteDir, 'leftover-incomplete'), { recursive: true }); + mkdirSync(join(sentinelDir, 'leftover-unrelated'), { recursive: true }); + + // Pausing the whole queue before adding keeps one job permanently non-history, + // which is what makes GetClaimedPathsAsync treat the queue as "busy". + let nzoId: string | undefined; + + try { + await driver.pauseQueue(); + nzoId = await driver.addWorkingNzb('busy-queue.bin', 300_000, 'e2e-ofc'); + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-unrelated'), 'a sentinel outside the incomplete dir proves the scan ran').toBe(true); + expect( + existsSync(join(incompleteDir, 'leftover-incomplete')), + 'a busy queue must claim every top-level entry defensively, including an unrelated one', + ).toBe(true); + } finally { + if (nzoId) { + await driver.deleteQueueJob(nzoId, true).catch(() => undefined); + } + await driver.resumeQueue(); + } + + // Queue is empty again (not busy): the same leftover is now a real orphan. + const trig2 = await triggerJob(token, 'DownloadCleaner'); + expect(trig2.ok, `triggerJob: ${trig2.status}`).toBe(true); + expect(await waitForMove(orphanedDir, 'leftover-incomplete'), 'leftover should be cleaned once the queue is idle').toBe(true); + }); + + test('a job in a category subfolder survives when that subfolder is the scan dir', async () => { + test.setTimeout(90_000); + const category = 'e2e-ofc-nested'; + const nestedDir = join(HOST_DOWNLOADS, 'complete', 'nested'); + resetDir(nestedDir); + const orphanedDir = join(HOST_DOWNLOADS, 'complete-orphaned'); + resetDir(orphanedDir); + + await driver.setCategoryDir(category, 'nested'); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [`${APP_DOWNLOADS}/complete/nested`], + orphanedDirectory: `${APP_DOWNLOADS}/complete-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + const dirName = `claim-nested-${Date.now().toString(36)}`; + driver.writeArticleFile(dirName, 'data1.bin', 32_768); + driver.writeArticleFile(dirName, 'data2.bin', 32_768); + const nzoId = await driver.addWorkingNzbDir(dirName, category); + await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + // A loose folder with no SABnzbd job behind it, sitting next to the job folder. + mkdirSync(join(nestedDir, 'leftover-unrelated'), { recursive: true }); + + try { + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-unrelated'), 'unrelated folder should have been moved out').toBe(true); + expect(existsSync(join(nestedDir, dirName)), 'the completed job folder in the category subfolder is claimed and must survive').toBe(true); + } finally { + await driver.deleteCategory(category); + } + }); + + test('a job in a category with an absolute folder outside complete_dir survives', async () => { + test.setTimeout(90_000); + const category = 'e2e-ofc-abs'; + const absDir = join(HOST_DOWNLOADS, 'e2e-abs'); + resetDir(absDir); + const orphanedDir = join(HOST_DOWNLOADS, 'complete-orphaned'); + resetDir(orphanedDir); + + await driver.setCategoryDir(category, '/downloads/e2e-abs'); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [`${APP_DOWNLOADS}/e2e-abs`], + orphanedDirectory: `${APP_DOWNLOADS}/complete-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + const dirName = `claim-abs-${Date.now().toString(36)}`; + driver.writeArticleFile(dirName, 'data1.bin', 32_768); + driver.writeArticleFile(dirName, 'data2.bin', 32_768); + const nzoId = await driver.addWorkingNzbDir(dirName, category); + await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + // A loose folder with no SABnzbd job behind it, sitting next to the job folder. + mkdirSync(join(absDir, 'leftover-unrelated'), { recursive: true }); + + try { + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-unrelated'), 'unrelated folder should have been moved out').toBe(true); + expect(existsSync(join(absDir, dirName)), 'the completed job folder in the absolute category folder is claimed and must survive').toBe(true); + } finally { + await driver.deleteCategory(category); + } + }); + + test('download_dir and complete_dir survive a scan rooted one level above them, an unrelated sibling moves', async () => { + test.setTimeout(90_000); + const completeDir = join(HOST_DOWNLOADS, 'complete'); + const incompleteDir = join(HOST_DOWNLOADS, 'incomplete'); + resetDir(completeDir); + resetDir(incompleteDir); + const orphanedDir = join(HOST_DOWNLOADS, 'root-orphaned'); + resetDir(orphanedDir); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [APP_DOWNLOADS], + orphanedDirectory: `${APP_DOWNLOADS}/root-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + const dirName = `claim-root-${Date.now().toString(36)}`; + driver.writeArticleFile(dirName, 'data1.bin', 32_768); + driver.writeArticleFile(dirName, 'data2.bin', 32_768); + const nzoId = await driver.addWorkingNzbDir(dirName, 'e2e-ofc'); + await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + // An unrelated top-level entry with no SABnzbd job or config directory behind it. + mkdirSync(join(HOST_DOWNLOADS, 'leftover-root'), { recursive: true }); + + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-root'), 'unrelated top-level folder should have been moved out').toBe(true); + expect(existsSync(incompleteDir), 'download_dir itself is claimed and must survive').toBe(true); + expect(existsSync(completeDir), 'complete_dir itself is claimed and must survive').toBe(true); + expect(existsSync(join(completeDir, dirName)), 'the completed job folder must survive').toBe(true); + }); + + test('download_dir and complete_dir survive a root-level scan with an idle queue and empty history', async () => { + test.setTimeout(60_000); + const completeDir = join(HOST_DOWNLOADS, 'complete'); + const incompleteDir = join(HOST_DOWNLOADS, 'incomplete'); + resetDir(completeDir); + resetDir(incompleteDir); + const orphanedDir = join(HOST_DOWNLOADS, 'root-orphaned'); + resetDir(orphanedDir); + + await driver.clearAll(); + + const ofc = await updateOrphanedFilesConfig(token, clientId, { + enabled: true, + scanDirectories: [APP_DOWNLOADS], + orphanedDirectory: `${APP_DOWNLOADS}/root-orphaned`, + minFileAgeHours: 0, + }); + expect(ofc.status).toBe(200); + + mkdirSync(join(HOST_DOWNLOADS, 'leftover-root'), { recursive: true }); + + const trig = await triggerJob(token, 'DownloadCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await waitForMove(orphanedDir, 'leftover-root'), 'unrelated top-level folder should have been moved out').toBe(true); + expect(existsSync(incompleteDir), 'download_dir must survive even with an empty history').toBe(true); + expect(existsSync(completeDir), 'complete_dir must survive even with an empty history').toBe(true); + }); +}); diff --git a/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts b/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts index 7ba18570..22994909 100644 --- a/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts +++ b/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts @@ -209,7 +209,6 @@ test.describe.serial('Seeding rule stop action', () => { enabled: true, name: `${s.key} stop rule e2e`, typeName: s.driver.typeName, - type: 'Torrent', host: s.driver.cleanuparrHost, username: s.driver.username ?? '', password: s.driver.password ?? '', diff --git a/e2e/tests/download-cleaner/transmission-session-handshake.spec.ts b/e2e/tests/download-cleaner/transmission-session-handshake.spec.ts index e1e70ed3..44c09d03 100644 --- a/e2e/tests/download-cleaner/transmission-session-handshake.spec.ts +++ b/e2e/tests/download-cleaner/transmission-session-handshake.spec.ts @@ -17,7 +17,6 @@ function payload(): Record { enabled: true, name: 'Transmission handshake e2e', typeName: transmission.typeName, - type: 'Torrent', host: transmission.cleanuparrHost, username: transmission.username, password: transmission.password, diff --git a/e2e/tests/download-cleaner/vanished-torrent-race.spec.ts b/e2e/tests/download-cleaner/vanished-torrent-race.spec.ts index b2f65070..b747c679 100644 --- a/e2e/tests/download-cleaner/vanished-torrent-race.spec.ts +++ b/e2e/tests/download-cleaner/vanished-torrent-race.spec.ts @@ -140,7 +140,6 @@ test.describe.serial('Download Cleaner with torrents deleted mid-pass', () => { enabled: true, name: 'qBittorrent vanish race e2e', typeName: qbit.typeName, - type: 'Torrent', host: qbit.cleanuparrHost, username: qbit.username ?? '', password: qbit.password ?? '', diff --git a/e2e/tests/download-client/download-client-crud.api.spec.ts b/e2e/tests/download-client/download-client-crud.api.spec.ts index 8913314d..b35de30c 100644 --- a/e2e/tests/download-client/download-client-crud.api.spec.ts +++ b/e2e/tests/download-client/download-client-crud.api.spec.ts @@ -3,7 +3,7 @@ import type { DownloadClientType } from '../helpers/api/download-client'; import { buildDownloadClientPayload } from '../helpers/api/download-client'; import { expectKeys } from '../helpers/contract'; -const TYPES: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'utorrent', 'rtorrent']; +const TYPES: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'utorrent', 'rtorrent', 'sabnzbd']; test.describe('DownloadClient — CRUD', () => { test('response shape is pinned and the password stays masked', async ({ api }) => { @@ -16,7 +16,7 @@ test.describe('DownloadClient — CRUD', () => { const created = await (await api.downloadClient.create(payload)).json(); const clientKeys = [ - 'downloadDirectorySource', 'downloadDirectoryTarget', 'enabled', 'externalUrl', 'host', + 'apiKey', 'downloadDirectorySource', 'downloadDirectoryTarget', 'enabled', 'externalUrl', 'host', 'id', 'name', 'password', 'type', 'typeName', 'urlBase', 'username', ]; expectKeys(created, clientKeys); @@ -34,8 +34,7 @@ test.describe('DownloadClient — CRUD', () => { const payload = buildDownloadClientPayload(type, { name: `${type}-e2e`, host: TEST_CONFIG.mocks.downloadClientUrl, - username: 'admin', - password: 'admin', + ...(type === 'sabnzbd' ? { apiKey: 'e2e-api-key' } : { username: 'admin', password: 'admin' }), }); const create = await api.downloadClient.create(payload); @@ -46,6 +45,7 @@ test.describe('DownloadClient — CRUD', () => { const created = await create.json(); expect(created.id).toBeTruthy(); expect(created.typeName.toLowerCase()).toBe(payload.typeName?.toLowerCase()); + expect(created.type).toBe(type === 'sabnzbd' ? 'Usenet' : 'Torrent'); const list = await (await api.downloadClient.list()).json(); const clients = list.clients ?? list; @@ -68,3 +68,105 @@ test.describe('DownloadClient — CRUD', () => { expect(res.status).toBe(401); }); }); + +test.describe('DownloadClient: invalid type validation', () => { + test('create with Unknown typeName returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'invalid-unknown', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + typeName: 'Unknown', + }); + const res = await api.downloadClient.create(payload); + expect(res.status).toBe(400); + }); + + test('create with undefined typeName (42) returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'invalid-42', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + typeName: '42', + }); + const res = await api.downloadClient.create(payload); + expect(res.status).toBe(400); + }); + + test('update with Unknown typeName returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'base-for-update', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }); + const created = await (await api.downloadClient.create(payload)).json(); + + const updatePayload = buildDownloadClientPayload('qbittorrent', { + name: 'invalid-update', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + typeName: 'Unknown', + }); + const res = await api.downloadClient.update(created.id, updatePayload); + expect(res.status).toBe(400); + + await api.downloadClient.delete(created.id); + }); + + test('update with undefined typeName (42) returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'base-for-update-42', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }); + const created = await (await api.downloadClient.create(payload)).json(); + + const updatePayload = buildDownloadClientPayload('qbittorrent', { + name: 'invalid-update-42', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + typeName: '42', + }); + const res = await api.downloadClient.update(created.id, updatePayload); + expect(res.status).toBe(400); + + await api.downloadClient.delete(created.id); + }); +}); + +test.describe('DownloadClient: API key validation', () => { + test('create SABnzbd with an empty API key returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('sabnzbd', { + name: 'invalid-sab-empty-key', + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: '', + }); + const res = await api.downloadClient.create(payload); + expect(res.status).toBe(400); + }); + + test('update a qBittorrent client to SABnzbd with the placeholder key returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'base-for-key-update', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }); + const created = await (await api.downloadClient.create(payload)).json(); + + const updatePayload = buildDownloadClientPayload('sabnzbd', { + name: 'invalid-key-update', + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: '••••••••', + }); + const res = await api.downloadClient.update(created.id, updatePayload); + expect(res.status).toBe(400); + + await api.downloadClient.delete(created.id); + }); +}); diff --git a/e2e/tests/download-client/download-client-test-connection.api.spec.ts b/e2e/tests/download-client/download-client-test-connection.api.spec.ts index be51f706..b80ec59b 100644 --- a/e2e/tests/download-client/download-client-test-connection.api.spec.ts +++ b/e2e/tests/download-client/download-client-test-connection.api.spec.ts @@ -61,6 +61,62 @@ test.describe('DownloadClient — test connection', () => { expect(res.ok).toBe(true); }); + test('sabnzbd: success on queue (apikey accepted)', async ({ api, mocks }) => { + await mocks.downloadClient.stub(DownloadClientStubs.sabQueueStub()); + const res = await api.downloadClient.test( + buildDownloadClientPayload('sabnzbd', { + name: 'sab-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: 'right-key', + }), + ); + expect(res.ok).toBe(true); + }); + + // mode=queue requires a valid apikey, unlike mode=version: a wrong key must fail here. + test('sabnzbd: a wrong api key fails test-connection', async ({ api, mocks }) => { + await mocks.downloadClient.stub(DownloadClientStubs.sabRejectedApiKeyStub('wrong-key')); + const res = await api.downloadClient.test( + buildDownloadClientPayload('sabnzbd', { + name: 'sab-conn-wrong-key', + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: 'wrong-key', + }), + ); + expect(res.ok).toBe(false); + }); + + // Reproduces #6: a new api key paired with the masked password placeholder must + // use the new key, not the stored one WireMock has already been told to reject. + test('sabnzbd: a new api key with a placeholder password succeeds', async ({ api, mocks }) => { + const created = await ( + await api.downloadClient.create( + buildDownloadClientPayload('sabnzbd', { + name: 'sab-resolve-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + password: 'stored-password', + apiKey: 'old-key', + }), + ) + ).json(); + + await mocks.downloadClient.stub(DownloadClientStubs.sabRejectedApiKeyStub('old-key')); + await mocks.downloadClient.stub(DownloadClientStubs.sabQueueStub()); + + const res = await api.downloadClient.test( + buildDownloadClientPayload('sabnzbd', { + name: 'sab-resolve-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + password: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022', + apiKey: 'new-key', + clientId: created.id, + }), + ); + expect(res.ok).toBe(true); + + await api.downloadClient.delete(created.id); + }); + test('any client: failure when host unreachable', async ({ api }) => { const res = await api.downloadClient.test( buildDownloadClientPayload('qbittorrent', { @@ -72,4 +128,28 @@ test.describe('DownloadClient — test connection', () => { ); expect(res.ok).toBe(false); }); + + test('test connection with Unknown typeName returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'test-unknown', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + typeName: 'Unknown', + }); + const res = await api.downloadClient.test(payload); + expect(res.status).toBe(400); + }); + + test('test connection with undefined typeName (42) returns 400', async ({ api }) => { + const payload = buildDownloadClientPayload('qbittorrent', { + name: 'test-42', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + typeName: '42', + }); + const res = await api.downloadClient.test(payload); + expect(res.status).toBe(400); + }); }); diff --git a/e2e/tests/download-client/download-clients-ui.spec.ts b/e2e/tests/download-client/download-clients-ui.spec.ts index 4df723cd..be3d89cc 100644 --- a/e2e/tests/download-client/download-clients-ui.spec.ts +++ b/e2e/tests/download-client/download-clients-ui.spec.ts @@ -29,6 +29,27 @@ test.describe('Download Clients UI', () => { await expect(urlBase).toHaveValue('plugins/httprpc/action.php'); }); + test('SABnzbd shows only the API key field', async ({ page }) => { + await loginAndGotoSettings(page, 'download-clients'); + await page.getByRole('button', { name: 'Add Client' }).click(); + const modal = page.getByRole('dialog', { name: 'Add Client' }); + await expect(modal).toBeVisible(); + + await selectOption(modal, 'Client Type', 'SABnzbd'); + + await expect(modal.locator('app-input').filter({ hasText: 'Username' })).toHaveCount(0); + await expect(modal.locator('app-input').filter({ hasText: 'Password' })).toHaveCount(0); + await expect(modal.locator('app-input').filter({ hasText: 'API Key' })).toBeVisible(); + + const save = modal.getByRole('button', { name: 'Save' }); + await modal.locator('app-input').first().locator('input').fill('E2E SAB Client'); // Name + await modal.locator('app-input').filter({ hasText: 'Host' }).locator('input').fill('http://localhost:8090'); + await expect(save).toBeDisabled(); + + await modal.locator('app-input').filter({ hasText: 'API Key' }).locator('input').fill('sab-api-key'); + await expect(save).toBeEnabled(); + }); + test('name and host required gate the modal Save button', async ({ page }) => { await loginAndGotoSettings(page, 'download-clients'); await page.getByRole('button', { name: 'Add Client' }).click(); @@ -43,4 +64,16 @@ test.describe('Download Clients UI', () => { await modal.locator('app-input').filter({ hasText: 'Host' }).locator('input').fill('http://localhost:8090'); await expect(save).toBeEnabled(); }); + + test('a failed types load shows the error state and recovers on retry', async ({ page }) => { + await page.route('**/download_client/types', (route) => route.abort()); + await loginAndGotoSettings(page, 'download-clients'); + + await expect(page.getByRole('heading', { name: 'Could not connect to server' })).toBeVisible(); + + await page.unroute('**/download_client/types'); + await page.getByRole('button', { name: 'Retry' }).click(); + + await expect(page.getByRole('button', { name: 'Add Client' })).toBeVisible(); + }); }); diff --git a/e2e/tests/download-client/sabnzbd-live.spec.ts b/e2e/tests/download-client/sabnzbd-live.spec.ts new file mode 100644 index 00000000..b5cf1afa --- /dev/null +++ b/e2e/tests/download-client/sabnzbd-live.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from '@playwright/test'; +import { + loginAndGetToken, + createDownloadClient, + deleteDownloadClient, + listDownloadClients, + createArrInstance, + deleteArrInstance, + triggerJob, +} from '../helpers/app-api'; +import { SabnzbdDriver } from '../helpers/usenet-clients/sabnzbd'; +import { WireMockClient, Mapping } from '../helpers/mocks/wiremock-client'; +import { TEST_CONFIG } from '../helpers/test-config'; +import { findEventWithDeleteReason } from '../helpers/api/signalr'; + +/** + * Live SABnzbd coverage: a real SABnzbd container downloading through the fake + * sabnews NNTP server, driving Cleanuparr's own QueueCleaner job against it. + * + * QueueCleaner never deletes a usenet job from SABnzbd itself: it only tells + * the arr to do so via DELETE /api/v3/queue/{id}?removeFromClient=true (the arr + * owns its download client relationship). Since Sonarr/Radarr in this suite are + * stubbed by wiremock-arr, "removed from the arr queue" is verified through its + * request journal, same as the existing malware-blocker.spec.ts pattern. + */ + +const POLL_TIMEOUT_MS = 30_000; + +interface QueueStubRecord { + id: number; + downloadId: string; + title: string; +} + +function arrHealthMapping(): Mapping { + return { + request: { method: 'GET', urlPathPattern: '/api/v[0-9]+/system/status' }, + response: { status: 200, jsonBody: { version: '4.0.0.0', appName: 'Sonarr' } }, + priority: 1, + }; +} + +function arrQueueMapping(records: QueueStubRecord[]): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/queue' }, + response: { + status: 200, + jsonBody: { + page: 1, + pageSize: records.length || 10, + totalRecords: records.length, + records: records.map((r) => ({ + id: r.id, + downloadId: r.downloadId, + title: r.title, + protocol: 'usenet', + seriesId: 1, + episodeId: 1, + seasonNumber: 1, + status: 'downloading', + trackedDownloadStatus: 'ok', + trackedDownloadState: 'downloading', + statusMessages: [], + sizeLeft: 0, + })), + }, + }, + priority: 1, + }; +} + +function arrCatchAllMapping(): Mapping { + return { + request: { method: 'ANY', urlPathPattern: '/api/v[0-9]+/.*' }, + response: { status: 200, jsonBody: {} }, + priority: 100, + }; +} + +async function stubArrQueue(arr: WireMockClient, records: QueueStubRecord[]): Promise { + await arr.resetAll(); + await arr.stubMany([arrHealthMapping(), arrQueueMapping(records), arrCatchAllMapping()]); +} + +async function deleteCallSeen(arr: WireMockClient, queueId: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const reqs = await arr.findRequests({ method: 'DELETE', urlPattern: `/api/v3/queue/${queueId}.*` }); + if (reqs.length > 0) { + return true; + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +test.describe.serial('SABnzbd live: download client + QueueCleaner', () => { + const driver = new SabnzbdDriver(); + const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); + let token: string; + let clientId: string; + let sonarrId: string; + + test.beforeAll(async () => { + test.setTimeout(60_000); + token = await loginAndGetToken(); + await driver.ready(); + await driver.clearAll(); + await driver.resumeQueue(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + const createRes = await createDownloadClient(token, { + enabled: true, + name: 'SABnzbd e2e', + typeName: 'Sabnzbd', + host: driver.cleanuparrHost, + apiKey: driver.apiKey, + }); + expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeLessThan(300); + clientId = (await createRes.json()).id; + + await arr.waitReady(); + await stubArrQueue(arr, []); + const sonarrRes = await createArrInstance(token, 'sonarr', { + name: 'SABnzbd QueueCleaner Stub', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'sab-qc-e2e', + version: 4, + }); + expect(sonarrRes.status).toBe(201); + sonarrId = (await sonarrRes.json()).id; + }); + + test.afterAll(async () => { + if (clientId) { + await deleteDownloadClient(token, clientId).catch(() => undefined); + } + if (sonarrId) { + await deleteArrInstance(token, 'sonarr', sonarrId).catch(() => undefined); + } + await driver.clearAll().catch(() => undefined); + await driver.resumeQueue().catch(() => undefined); + }); + + test('CRUD: update and list round-trip', async () => { + const list = await listDownloadClients(token); + expect(list.some((c) => c.id === clientId)).toBe(true); + }); + + test('a job that fails fast (missing article) is removed from the arr queue', async () => { + test.setTimeout(180_000); + const nzoId = await driver.addMissingSegmentNzb('qc-failed.bin', 'e2e-qc'); + // A missing article can sit out SABnzbd's 60s news server timeout before the job fails. + await driver.waitForHistoryStatus(nzoId, 'Failed', 120_000); + + await stubArrQueue(arr, [{ id: 9001, downloadId: nzoId, title: 'qc-failed' }]); + const trig = await triggerJob(token, 'QueueCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + expect(await deleteCallSeen(arr, 9001, POLL_TIMEOUT_MS)).toBe(true); + + const event = await findEventWithDeleteReason(token, nzoId, 'DownloadFailed', POLL_TIMEOUT_MS); + expect(event, 'a DownloadFailed event should have been emitted for the failed SABnzbd job').toBeDefined(); + + // QueueCleaner never calls SABnzbd to delete the job itself: removal from the + // client is the arr's job, driven by the removeFromClient query param above. + // With a stubbed arr (not a real Sonarr wired to this SABnzbd), the job is left + // untouched here; a real arr would issue its own SABnzbd history delete. + const { history } = await driver.findJob(nzoId); + expect(history?.status).toBe('Failed'); + }); + + test('a job still in the queue is left alone', async () => { + test.setTimeout(90_000); + // The whole queue is paused before the job is added, so it never starts + // downloading and can't race its way into history before being checked. + let nzoId: string | undefined; + + try { + await driver.pauseQueue(); + nzoId = await driver.addWorkingNzb('qc-downloading.bin', 300_000, 'e2e-qc'); + await stubArrQueue(arr, [{ id: 9002, downloadId: nzoId, title: 'qc-downloading' }]); + const trig = await triggerJob(token, 'QueueCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + await new Promise((r) => setTimeout(r, 4_000)); + + expect(await deleteCallSeen(arr, 9002, 4_000)).toBe(false); + const { queue } = await driver.findJob(nzoId); + expect(queue, 'an in-progress job must still be in the queue').toBeDefined(); + } finally { + if (nzoId) { + await driver.deleteQueueJob(nzoId, true).catch(() => undefined); + } + await driver.resumeQueue(); + } + }); + + test('a completed job is left alone (no slow-rule strike applies to SABnzbd)', async () => { + test.setTimeout(90_000); + const nzoId = await driver.addWorkingNzb('qc-completed.bin', 300_000, 'e2e-qc'); + await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + await stubArrQueue(arr, [{ id: 9003, downloadId: nzoId, title: 'qc-completed' }]); + const trig = await triggerJob(token, 'QueueCleaner'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + await new Promise((r) => setTimeout(r, 4_000)); + + expect(await deleteCallSeen(arr, 9003, 4_000)).toBe(false); + }); +}); diff --git a/e2e/tests/download-client/sabnzbd-timeout.spec.ts b/e2e/tests/download-client/sabnzbd-timeout.spec.ts new file mode 100644 index 00000000..7ca502c8 --- /dev/null +++ b/e2e/tests/download-client/sabnzbd-timeout.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { + loginAndGetToken, + testDownloadClient, + getGeneralConfig, + updateGeneralConfig, +} from '../helpers/app-api'; +import { WireMockClient } from '../helpers/mocks/wiremock-client'; +import { TEST_CONFIG } from '../helpers/test-config'; + +/** + * A SABnzbd request that outlives the configured HttpClient.Timeout used to surface + * as an unwrapped TaskCanceledException. Test-connection must still fail cleanly + * (not hang and not crash) once SabnzbdClient wraps it with the same mode context + * as the HttpRequestException path. + */ + +const HTTP_TIMEOUT_SECONDS = 2; +const RESPONSE_DELAY_MS = 5_000; + +test.describe.serial('SABnzbd request timeout', () => { + const downloadClientMock = new WireMockClient(TEST_CONFIG.mocks.downloadClientAdminUrl); + let token: string; + let originalGeneralConfig: Record; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + await downloadClientMock.waitReady(); + + originalGeneralConfig = await getGeneralConfig(token); + await updateGeneralConfig(token, { + ...originalGeneralConfig, + httpTimeout: HTTP_TIMEOUT_SECONDS, + httpMaxRetries: 0, + }); + }); + + test.afterEach(async () => { + await downloadClientMock.resetAll(); + }); + + test.afterAll(async () => { + await updateGeneralConfig(token, originalGeneralConfig).catch(() => {}); + }); + + test('test-connection fails cleanly instead of hanging', async () => { + await downloadClientMock.stub({ + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: 'queue' } } }, + response: { status: 200, jsonBody: { queue: { slots: [] } }, fixedDelayMilliseconds: RESPONSE_DELAY_MS }, + }); + + const startedAt = Date.now(); + const res = await testDownloadClient(token, { + enabled: true, + name: 'SABnzbd timeout e2e', + typeName: 'Sabnzbd', + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: 'e2e-key', + }); + const elapsedMs = Date.now() - startedAt; + + expect(res.ok, 'test-connection must fail, not succeed, against a stalled client').toBe(false); + expect( + elapsedMs, + `test-connection took ${elapsedMs}ms: it must fail around the configured timeout, not the response delay`, + ).toBeLessThan(RESPONSE_DELAY_MS); + }); +}); diff --git a/e2e/tests/helpers/api/download-client.ts b/e2e/tests/helpers/api/download-client.ts index f3fb6343..d66cc1c6 100644 --- a/e2e/tests/helpers/api/download-client.ts +++ b/e2e/tests/helpers/api/download-client.ts @@ -5,23 +5,23 @@ export type DownloadClientType = | 'transmission' | 'deluge' | 'utorrent' - | 'rtorrent'; - -export type DownloadClientCategory = 'Torrent' | 'Usenet'; + | 'rtorrent' + | 'sabnzbd'; export interface DownloadClientPayload { name: string; - /** Backend enum value (Torrent / Usenet). */ - type?: DownloadClientCategory; /** Backend type-name enum value (qBittorrent / Deluge / Transmission / uTorrent / rTorrent). */ typeName?: string; /** Full URL including scheme + port. */ host: string; username?: string; password?: string; + apiKey?: string; urlBase?: string; externalUrl?: string; enabled?: boolean; + /** Existing client id, for test-connection requests that resolve a masked secret. */ + clientId?: string; } const TYPE_NAME_MAP: Record = { @@ -30,6 +30,7 @@ const TYPE_NAME_MAP: Record = { deluge: 'Deluge', utorrent: 'uTorrent', rtorrent: 'rTorrent', + sabnzbd: 'Sabnzbd', }; export function buildDownloadClientPayload( @@ -37,7 +38,6 @@ export function buildDownloadClientPayload( overrides: Partial & { host: string; name: string }, ): DownloadClientPayload { return { - type: 'Torrent', typeName: TYPE_NAME_MAP[type], enabled: true, ...overrides, diff --git a/e2e/tests/helpers/api/signalr.ts b/e2e/tests/helpers/api/signalr.ts index bee96d3c..5dfedae3 100644 --- a/e2e/tests/helpers/api/signalr.ts +++ b/e2e/tests/helpers/api/signalr.ts @@ -59,3 +59,36 @@ export async function waitForEvent( } }); } + +/** + * Polls `GetRecentEvents` over SignalR until an event matching the given item hash and + * delete reason shows up, or the timeout elapses. Matching is case-insensitive on the hash. + */ +export async function findEventWithDeleteReason( + token: string, + itemHash: string, + deleteReason: string, + timeoutMs: number, +): Promise | undefined> { + const connection = buildHubConnection({ accessToken: token, hubUrl: '/api/hubs/app' }); + await connection.start(); + + try { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const received = waitForEvent>>(connection, 'EventsReceived'); + await connection.invoke('GetRecentEvents', 20); + const events = await received; + const match = events.find( + (e) => String(e.itemHash).toLowerCase() === itemHash.toLowerCase() && e.deleteReason === deleteReason, + ); + if (match) { + return match; + } + await new Promise((r) => setTimeout(r, 500)); + } + return undefined; + } finally { + await connection.stop(); + } +} diff --git a/e2e/tests/helpers/live-lazylibrarian.ts b/e2e/tests/helpers/live-lazylibrarian.ts index c372e2fb..f16d9df1 100644 --- a/e2e/tests/helpers/live-lazylibrarian.ts +++ b/e2e/tests/helpers/live-lazylibrarian.ts @@ -1,4 +1,5 @@ import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { expect } from '@playwright/test'; import type { CleanuparrApi } from './api'; @@ -6,6 +7,7 @@ import { buildDownloadClientPayload } from './api/download-client'; import { TEST_CONFIG, farFutureCron } from './test-config'; import { buildMultiFileTorrent, buildSingleFileTorrent, type GeneratedTorrent } from './torrent-fixtures'; import { QBittorrentDriver } from './torrent-clients/qbittorrent'; +import { buildMissingSegmentNzb, type SabnzbdDriver } from './usenet-clients/sabnzbd'; import { WireMockClient, type Mapping } from './mocks/wiremock-client'; /** @@ -48,6 +50,9 @@ export type BookLibrary = 'eBook' | 'AudioBook'; */ export const TORZNAB_BOOK_CATEGORY = 8010; +/** Newznab's ebook category, which LazyLibrarian asks for alongside 7000. */ +const NEWZNAB_BOOK_CATEGORY = 7020; + /** Outside the qBittorrent save path, so the grabbed torrent stalls. */ const TORRENT_SOURCE_DIR = resolve(__dirname, '..', '..', 'test-data', 'torznab-src'); @@ -151,9 +156,19 @@ export const liveLazyLibrarian = new LiveLazyLibrarian( export const indexerMock = new WireMockClient(TEST_CONFIG.mocks.indexerAdminUrl); export const qbittorrent = new QBittorrentDriver(); -function bookFeed(title: string, file: string): string { +/** LazyLibrarian drops a Newznab result that carries seeders, so only the Torznab feed sends them. */ +function bookFeed(title: string, file: string, protocol: 'torrent' | 'usenet'): string { + const ns = protocol === 'torrent' ? 'torznab' : 'newznab'; + const schema = protocol === 'torrent' ? 'http://torznab.com/schemas/2015/feed' : 'http://www.newznab.com/DTD/2010/feeds/attributes/'; + const type = protocol === 'torrent' ? 'application/x-bittorrent' : 'application/x-nzb'; + const attrs = protocol === 'torrent' + ? ` + + ` + : ``; + return ` - + E2E Indexer ${TEST_CONFIG.mocks.indexerUrl}/ @@ -164,10 +179,8 @@ function bookFeed(title: string, file: string): string { ${TEST_CONFIG.mocks.indexerUrl}/dl/${file} Mon, 01 Jan 2024 00:00:00 +0000 ${ADVERTISED_SIZE_BYTES} - - - - + + ${attrs} `; @@ -179,7 +192,7 @@ function bookFeed(title: string, file: string): string { * LazyLibrarian tries several modes for one book, so this stub matches none. * The capabilities mapping keeps priority 1 and still answers `t=caps`. */ -function bookSearchStub(title: string, file: string): Mapping { +function bookSearchStub(title: string, file: string, protocol: 'torrent' | 'usenet' = 'torrent'): Mapping { return { priority: 2, request: { @@ -189,7 +202,7 @@ function bookSearchStub(title: string, file: string): Mapping { response: { status: 200, headers: { 'Content-Type': 'application/xml' }, - body: bookFeed(title, file), + body: bookFeed(title, file, protocol), }, }; } @@ -205,6 +218,18 @@ function torrentStub(file: string, metainfo: Buffer): Mapping { }; } +/** SABnzbd itself fetches the NZB (LazyLibrarian hands it the URL via `mode=addurl`), not LazyLibrarian. */ +function nzbStub(file: string): Mapping { + return { + request: { method: 'GET', urlPath: `/dl/${file}` }, + response: { + status: 200, + headers: { 'Content-Type': 'application/x-nzb' }, + body: buildMissingSegmentNzb(file), + }, + }; +} + export interface SnatchOptions { /** Files inside the torrent, for the Malware Blocker specs. */ files?: Array<{ filename: string; sizeBytes: number }>; @@ -267,6 +292,34 @@ export async function prepareRelease( return { bookId: book.BookID, releaseTitle, torrent, library }; } +/** + * Registers the Newznab stubs for a book (search feed + NZB download) and snatches it. + * + * The NZB references a missing sabnews article, so SABnzbd fails the job fast once it + * tries to download it. Needs {@link pointLazyLibrarianAtSabnzbd} to have run first, + * since the seeded LazyLibrarian only has a Torznab provider and a qBittorrent client. + */ +export async function snatchBookViaSabnzbd(book: LazyLibrarianBook): Promise { + const releaseTitle = `${book.AuthorName} - ${book.BookName}`.replace(/[^\w\s.-]/g, ' ').trim(); + const file = `${book.BookID}.nzb`; + + await indexerMock.stubMany([bookSearchStub(releaseTitle, file, 'usenet'), nzbStub(file)]); + + await liveLazyLibrarian.markWanted(book.BookID); + await liveLazyLibrarian.searchBook(book.BookID); + + const row = await waitForSnatchedRow(book.BookID); + + return { + bookId: book.BookID, + releaseTitle, + // SABnzbd's nzo_id, not a hash: LazyLibrarian records whatever its downloader returns. + downloadId: row.DownloadID, + torrentName: releaseTitle, + origin: row.Origin, + }; +} + /** Runs the search and grab for a release whose stubs are already registered. */ export async function snatchPreparedRelease(prepared: PreparedRelease): Promise { await liveLazyLibrarian.markWanted(prepared.bookId, prepared.library); @@ -415,6 +468,22 @@ export async function createDownloadClient(api: CleanuparrApi): Promise return created.id; } +/** The Cleanuparr-side SABnzbd client, for a spec that points LazyLibrarian at SABnzbd. */ +export async function createSabnzbdDownloadClient(api: CleanuparrApi, sab: SabnzbdDriver): Promise { + const created = await ( + await api.downloadClient.create( + buildDownloadClientPayload('sabnzbd', { + name: 'live-lazylibrarian sabnzbd', + host: sab.cleanuparrHost, + apiKey: sab.apiKey, + }), + ) + ).json(); + + expect(created.id, 'createSabnzbdDownloadClient').toBeTruthy(); + return created.id; +} + export interface LiveInstanceIds { instanceId: string; clientId: string; @@ -546,6 +615,74 @@ export async function runUntilStrikes(api: CleanuparrApi, downloadId: string, st } } +const COMPOSE_FILE = resolve(__dirname, '..', '..', 'docker-compose.e2e.yml'); +const LAZYLIBRARIAN_CONFIG_PATH = resolve(__dirname, '..', '..', 'test-data', 'lazylibrarian-config', 'config.ini'); + +let savedLazyLibrarianConfig: string | undefined; + +function composeCommand(cmd: string): void { + execSync(`docker compose -f ${COMPOSE_FILE} ${cmd}`, { stdio: 'inherit', env: process.env }); +} + +/** + * Points the seeded LazyLibrarian at SABnzbd, on top of its existing qBittorrent/Torznab setup. + * + * LazyLibrarian only writes config.ini on shutdown, and only reads it on startup, so this stops + * the container, edits the file on disk, and starts it again (same dance `seed-lazylibrarian.sh` + * uses to write the seed in the first place). Pair with {@link restoreLazyLibrarianConfig}. + */ +export async function pointLazyLibrarianAtSabnzbd(sab: SabnzbdDriver): Promise { + composeCommand('stop lazylibrarian'); + + savedLazyLibrarianConfig = readFileSync(LAZYLIBRARIAN_CONFIG_PATH, 'utf8'); + + const sabUrl = new URL(sab.cleanuparrHost); + const indexerUrl = TEST_CONFIG.mocks.indexerUrl; + // Matches scripts/seed-lazylibrarian.sh's INDEXER_API_KEY: the stub never checks it. + const indexerApiKey = 'e2e-indexer-key'; + + // The seeded Torznab provider hits the same indexer mock on the same /api path, so it must + // be disabled: otherwise it also answers the Newznab search and tries to bencode-decode the NZB. + const withoutTorznab = savedLazyLibrarianConfig.replace( + /(\[Torznab_0\][^[]*?enabled = )True/, + '$1False', + ); + + writeFileSync( + LAZYLIBRARIAN_CONFIG_PATH, + `${withoutTorznab}\n` + + '[SABnzbd]\n' + + `sab_host = ${sabUrl.hostname}\n` + + `sab_port = ${sabUrl.port}\n` + + `sab_api = ${sab.apiKey}\n\n` + + '[USENET]\n' + + 'nzb_downloader_sabnzbd = True\n\n' + + '[Newznab_0]\n' + + 'dispname = E2E Newznab\n' + + 'enabled = True\n' + + `host = ${indexerUrl}\n` + + `api = ${indexerApiKey}\n` + + 'generalsearch = search\n' + + 'manual = True\n', + ); + + composeCommand('up -d lazylibrarian'); + await liveLazyLibrarian.waitReady(); +} + +/** Undoes {@link pointLazyLibrarianAtSabnzbd}, restoring the config every other live-lazylibrarian spec expects. */ +export async function restoreLazyLibrarianConfig(): Promise { + if (!savedLazyLibrarianConfig) { + return; + } + + composeCommand('stop lazylibrarian'); + writeFileSync(LAZYLIBRARIAN_CONFIG_PATH, savedLazyLibrarianConfig); + savedLazyLibrarianConfig = undefined; + composeCommand('up -d lazylibrarian'); + await liveLazyLibrarian.waitReady(); +} + /** getAllBooks has no ORDER BY, so a spec must claim by work id. */ export function claimBookById(books: LazyLibrarianBook[], bookId: string): LazyLibrarianBook { const book = books.find((candidate) => candidate.BookID === bookId); diff --git a/e2e/tests/helpers/mocks/arr-stubs.ts b/e2e/tests/helpers/mocks/arr-stubs.ts index 99e53a41..9668b34c 100644 --- a/e2e/tests/helpers/mocks/arr-stubs.ts +++ b/e2e/tests/helpers/mocks/arr-stubs.ts @@ -92,6 +92,14 @@ export function arrRawQueuePageStub(page: number, body: string): Mapping { }; } +/** Accepts a queue item removal, so Cleanuparr records it as done and emits its event. */ +export function arrQueueDeleteStub(): Mapping { + return { + request: { method: 'DELETE', urlPathPattern: '/api/v3/queue/.*' }, + response: { status: 200, jsonBody: {} }, + }; +} + export function arrQueueStub(records: QueueRecord[]): Mapping { return { request: { method: 'GET', urlPath: '/api/v3/queue' }, diff --git a/e2e/tests/helpers/mocks/download-client-stubs.ts b/e2e/tests/helpers/mocks/download-client-stubs.ts index 221eabe4..19c293e9 100644 --- a/e2e/tests/helpers/mocks/download-client-stubs.ts +++ b/e2e/tests/helpers/mocks/download-client-stubs.ts @@ -76,6 +76,89 @@ export function utorrentStubs(token = 'utorrent-token', guid = 'test-guid'): Map ]; } +export interface SabQueueSlotStub { + nzo_id: string; + filename?: string; + status?: string; + cat?: string; +} + +export function sabQueueStub(slots: SabQueueSlotStub[] = [], kbpersec = '0.00', paused = false): Mapping { + return { + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: 'queue' } } }, + response: { + status: 200, + jsonBody: { + queue: { + paused, + kbpersec, + slots: slots.map((s) => ({ filename: s.nzo_id, status: 'Downloading', cat: '*', ...s })), + }, + }, + }, + }; +} + +export interface SabHistorySlotStub { + nzo_id: string; + name?: string; + status?: string; + storage?: string; + fail_message?: string; +} + +export function sabHistoryStub(slots: SabHistorySlotStub[] = []): Mapping { + return { + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: 'history' } } }, + response: { + status: 200, + jsonBody: { + history: { + slots: slots.map((s) => ({ name: s.nzo_id, status: 'Completed', storage: '', fail_message: '', ...s })), + }, + }, + }, + }; +} + +export function sabUnreachableStub(): Mapping { + return { + request: { method: 'GET', urlPath: '/api' }, + response: { status: 500 }, + }; +} + +/** A 200 response with no `queue`/`history` key at all: version drift or a proxy rewrite. */ +export function sabEmptyBodyStub(mode: 'queue' | 'history'): Mapping { + return { + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: mode } } }, + response: { status: 200, jsonBody: {} }, + }; +} + +export function sabGetConfigStub(downloadDir: string): Mapping { + return { + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: 'get_config' } } }, + response: { status: 200, jsonBody: { config: { misc: { download_dir: downloadDir } } } }, + }; +} + +export function sabGetConfigErrorStub(): Mapping { + return { + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: 'get_config' } } }, + response: { status: 500 }, + }; +} + +/** Real SABnzbd rejects `mode=queue` (and any other authenticated mode) for a wrong apikey with HTTP 403 plain text. */ +export function sabRejectedApiKeyStub(apiKey: string): Mapping { + return { + request: { method: 'GET', urlPath: '/api', queryParameters: { mode: { equalTo: 'queue' }, apikey: { equalTo: apiKey } } }, + response: { status: 403, body: 'API Key Incorrect' }, + priority: 1, + }; +} + export function rtorrentXmlRpcStub(): Mapping { return { request: { method: 'POST', urlPath: '/RPC2' }, diff --git a/e2e/tests/helpers/usenet-clients/sabnzbd.ts b/e2e/tests/helpers/usenet-clients/sabnzbd.ts new file mode 100644 index 00000000..da5c6c20 --- /dev/null +++ b/e2e/tests/helpers/usenet-clients/sabnzbd.ts @@ -0,0 +1,254 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { pollUntilOk } from '../torrent-clients/types'; +import { mkdirShared, writeFileShared } from '../shared-volume'; + +/** Host directory bind-mounted as /data in the sabnews container (see docker-compose.e2e.yml). */ +const HOST_ARTICLES_DIR = resolve(__dirname, '..', '..', '..', 'test-data', 'sabnews-articles'); +const CONTAINER_ARTICLES_DIR = '/data'; +const COMPOSE_FILE = resolve(__dirname, '..', '..', '..', 'docker-compose.e2e.yml'); + +export interface SabnzbdQueueSlot { + nzo_id: string; + filename: string; + status: string; + cat: string; +} + +export interface SabnzbdHistorySlot { + nzo_id: string; + name: string; + status: string; + storage: string; + fail_message: string; + category: string; +} + +/** + * An NZB referencing an article file that does not exist, so SABnzbd fails the job fast. + * + * Shared by {@link SabnzbdDriver.addMissingSegmentNzb} (uploaded directly) and the + * live-lazylibrarian Newznab stub (served over HTTP for SABnzbd's own `addurl` to fetch). + */ +export function buildMissingSegmentNzb(fileName: string): string { + const missingPath = `${CONTAINER_ARTICLES_DIR}/missing-${fileName}`; + return [ + '', + '', + '', + ``, + 'alt.binaries.test', + '', + `file=${missingPath}|part=1|start=0|size=500000`, + '', + '', + '', + '', + ].join('\n'); +} + +/** + * Driver for the live SABnzbd container used by the e2e usenet specs. + * + * Unlike the torrent drivers, "adding a download" means handing SABnzbd an NZB that + * resolves against the fake `sabnews` NNTP server, so the job really downloads instead + * of being faked into existence. `addWorkingNzb` shells out to `docker compose exec` to + * run SABnzbd's own `tests/sabnews.py --nzbfile` generator inside the sabnews container + * (it needs to run where the referenced article path resolves). `addMissingSegmentNzb` + * builds a corrupt NZB by hand instead: no generator round trip needed to make SABnzbd + * fail to find an article. + */ +export class SabnzbdDriver { + readonly apiKey: string; + private readonly host: string; + + constructor(host = 'http://localhost:8070', apiKey = '0000000000000000000000000000e2e5') { + this.host = host; + this.apiKey = apiKey; + } + + /** URL the Cleanuparr backend should be configured with. */ + get cleanuparrHost(): string { + return this.host; + } + + async ready(): Promise { + await pollUntilOk(async () => (await this.call('version')).ok, { label: 'SABnzbd API' }); + } + + private async call(mode: string, params: Record = {}): Promise { + const query = new URLSearchParams({ mode, output: 'json', apikey: this.apiKey, ...params }); + return fetch(`${this.host}/api?${query.toString()}`); + } + + private async json(mode: string, params: Record = {}): Promise { + const res = await this.call(mode, params); + if (!res.ok) { + throw new Error(`SABnzbd ${mode} failed: ${res.status} ${await res.text()}`); + } + return res.json() as Promise; + } + + /** Builds a real NZB via SABnzbd's own fake-news-server generator and adds it under `category`. */ + async addWorkingNzb(fileName: string, contentBytes: number, category: string): Promise { + mkdirShared(HOST_ARTICLES_DIR); + const hostPath = join(HOST_ARTICLES_DIR, fileName); + writeFileShared(hostPath, Buffer.alloc(contentBytes, 1)); + + execFileSync( + 'docker', + ['compose', '-f', COMPOSE_FILE, 'exec', '-T', 'sabnews', 'python3', '/sabnews.py', '--nzbfile', `${CONTAINER_ARTICLES_DIR}/${fileName}`], + { stdio: 'pipe' }, + ); + + const nzbPath = hostPath.replace(/\.[^.]+$/, '') + '.nzb'; + return this.addNzbFile(nzbPath, category); + } + + /** Writes a deterministic-size article file under the shared sabnews articles directory. */ + writeArticleFile(dirName: string, fileName: string, sizeBytes: number): void { + const hostDir = join(HOST_ARTICLES_DIR, dirName); + mkdirShared(hostDir); + writeFileShared(join(hostDir, fileName), Buffer.alloc(sizeBytes, 1)); + } + + /** + * Builds a single NZB covering every file already written under `dirName` + * (one `` entry per file), so the job lands in one job folder with + * multiple files: the usenet equivalent of a multi-file torrent. + */ + async addWorkingNzbDir(dirName: string, category: string): Promise { + const hostDir = join(HOST_ARTICLES_DIR, dirName); + execFileSync( + 'docker', + ['compose', '-f', COMPOSE_FILE, 'exec', '-T', 'sabnews', 'python3', '/sabnews.py', '--nzbdir', `${CONTAINER_ARTICLES_DIR}/${dirName}`], + { stdio: 'pipe' }, + ); + const nzbPath = join(hostDir, `${dirName}.nzb`); + return this.addNzbFile(nzbPath, category); + } + + /** Builds an NZB referencing an article file that does not exist, so SABnzbd fails the job fast. */ + async addMissingSegmentNzb(fileName: string, category: string): Promise { + mkdirShared(HOST_ARTICLES_DIR); + const hostNzbPath = join(HOST_ARTICLES_DIR, `${fileName}.nzb`); + writeFileShared(hostNzbPath, buildMissingSegmentNzb(fileName)); + return this.addNzbFile(hostNzbPath, category); + } + + private async addNzbFile(nzbPath: string, category: string): Promise { + const form = new FormData(); + form.append('name', new Blob([readFileSync(nzbPath)]), basename(nzbPath)); + form.append('cat', category); + // pp=0 (download only, no repair/unpack): fixture files are raw bytes, not + // real archives, and SABnzbd's unpack step spends up to its own timeout + // trying to make sense of them before giving up. + form.append('pp', '0'); + const query = new URLSearchParams({ mode: 'addfile', output: 'json', apikey: this.apiKey }); + const res = await fetch(`${this.host}/api?${query.toString()}`, { method: 'POST', body: form }); + if (!res.ok) { + throw new Error(`SABnzbd addfile failed: ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as { status: boolean; nzo_ids?: string[]; error?: string }; + if (!body.status || !body.nzo_ids?.[0]) { + throw new Error(`SABnzbd addfile rejected the job: ${body.error ?? JSON.stringify(body)}`); + } + return body.nzo_ids[0]; + } + + /** Pauses the whole queue, so a job added afterwards never starts downloading. */ + async pauseQueue(): Promise { + await this.call('pause'); + } + + async resumeQueue(): Promise { + await this.call('resume'); + } + + async pauseJob(nzoId: string): Promise { + await this.call('queue', { name: 'pause', value: nzoId }); + } + + async resumeJob(nzoId: string): Promise { + await this.call('queue', { name: 'resume', value: nzoId }); + } + + /** Percent of the configured bandwidth cap (0-100). SABnzbd has no per-job speed limit. */ + async setSpeedLimit(percent: number): Promise { + await this.call('config', { name: 'speedlimit', value: String(percent) }); + } + + /** + * Creates (or repoints) a category with its own output folder. `dir` is relative to + * complete_dir unless it starts with `/`, in which case SABnzbd uses it as-is. `name` + * is required alongside `keyword` or SABnzbd won't create a category that doesn't exist yet. + */ + async setCategoryDir(category: string, dir: string): Promise { + await this.call('set_config', { section: 'categories', name: category, keyword: category, dir }); + } + + /** Removes a category added by {@link setCategoryDir}. SABnzbd 5.1.3 only exposes this as `del_config`. */ + async deleteCategory(category: string): Promise { + await this.call('del_config', { section: 'categories', keyword: category }); + } + + async listQueue(): Promise { + const data = await this.json<{ queue: { slots: SabnzbdQueueSlot[] } }>('queue'); + return data.queue.slots; + } + + async listHistory(): Promise { + const data = await this.json<{ history: { slots: SabnzbdHistorySlot[] } }>('history', { limit: '0' }); + return data.history.slots; + } + + async findJob(nzoId: string): Promise<{ queue?: SabnzbdQueueSlot; history?: SabnzbdHistorySlot }> { + const [queue, history] = await Promise.all([this.listQueue(), this.listHistory()]); + return { + queue: queue.find((s) => s.nzo_id === nzoId), + history: history.find((s) => s.nzo_id === nzoId), + }; + } + + /** + * Polls history until `status` is reached, then waits once more for the same + * status to settle. A job can briefly show "Completed" in the API while + * SABnzbd's post-processing (sorting/renaming) is still moving the final + * file into place, so a single poll hit is not proof the files are done moving. + */ + async waitForHistoryStatus(nzoId: string, status: string, timeoutMs = 60_000): Promise { + const start = Date.now(); + let slot: SabnzbdHistorySlot | undefined; + while (Date.now() - start < timeoutMs) { + const { history } = await this.findJob(nzoId); + if (history?.status === status) { + slot = history; + break; + } + await new Promise((r) => setTimeout(r, 1000)); + } + if (!slot) { + throw new Error(`Timed out waiting for SABnzbd job ${nzoId} to reach status "${status}"`); + } + await new Promise((r) => setTimeout(r, 3000)); + const { history } = await this.findJob(nzoId); + return history ?? slot; + } + + async deleteHistoryJob(nzoId: string, deleteFiles: boolean): Promise { + await this.call('history', { name: 'delete', value: nzoId, del_files: deleteFiles ? '1' : '0', archive: '0' }); + } + + async deleteQueueJob(nzoId: string, deleteFiles: boolean): Promise { + await this.call('queue', { name: 'delete', value: nzoId, del_files: deleteFiles ? '1' : '0' }); + } + + /** Empties the queue and permanently deletes history (archive=0), so re-runs start clean. */ + async clearAll(): Promise { + await this.setSpeedLimit(100); + const [queue, history] = await Promise.all([this.listQueue(), this.listHistory()]); + await Promise.all(queue.map((s) => this.deleteQueueJob(s.nzo_id, true))); + await Promise.all(history.map((s) => this.deleteHistoryJob(s.nzo_id, true))); + } +} diff --git a/e2e/tests/live-lazylibrarian/sabnzbd-snatch.api.spec.ts b/e2e/tests/live-lazylibrarian/sabnzbd-snatch.api.spec.ts new file mode 100644 index 00000000..5419102a --- /dev/null +++ b/e2e/tests/live-lazylibrarian/sabnzbd-snatch.api.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '../fixtures/base'; +import { adminTokens } from '../helpers/test-lifecycle'; +import { findEventWithDeleteReason } from '../helpers/api/signalr'; +import { SabnzbdDriver } from '../helpers/usenet-clients/sabnzbd'; +import { + claimBookById, + createSabnzbdDownloadClient, + type LazyLibrarianBook, + liveLazyLibrarian, + pinQueueCleanerSchedule, + pointLazyLibrarianAtSabnzbd, + resetLibrary, + restoreLazyLibrarianConfig, + setSearchEnabled, + snatchBookViaSabnzbd, + teardownLiveInstance, +} from '../helpers/live-lazylibrarian'; + +/** + * LazyLibrarian snatching to SABnzbd, against a real LazyLibrarian and a real SABnzbd. + * + * The seeded LazyLibrarian only knows qBittorrent + a Torznab provider, so + * `pointLazyLibrarianAtSabnzbd` stops the container, adds a SABnzbd downloader and a + * Newznab provider to its config.ini, and starts it again: the same stop/edit/start + * dance `scripts/seed-lazylibrarian.sh` uses to write the seed, since LazyLibrarian + * only persists config on shutdown and only reads it on startup. + * + * The NZB SABnzbd receives references a missing sabnews article, so the job reaches + * history as Failed fast, without needing a real multi-part download to complete. + */ + +const REMOVAL_TIMEOUT = 60_000; +const EVENT_TIMEOUT = 30_000; + +// An OpenLibrary work id from seed-lazylibrarian.sh that no other live-lazylibrarian spec claims. +const BOOK_ID = 'OL24034W'; + +const sabnzbd = new SabnzbdDriver(); + +let books: LazyLibrarianBook[] = []; +let instanceId: string | undefined; +let clientId: string | undefined; + +test.describe.configure({ mode: 'serial' }); + +test.describe('LazyLibrarian snatching to a live SABnzbd', () => { + test.beforeAll(async () => { + test.setTimeout(180_000); + + await Promise.all([liveLazyLibrarian.waitReady(), sabnzbd.ready()]); + books = await liveLazyLibrarian.books(); + expect(books.length, 'the LazyLibrarian seed has no books').toBeGreaterThan(0); + + await resetLibrary(books); + await sabnzbd.clearAll(); + await pointLazyLibrarianAtSabnzbd(sabnzbd); + }); + + test.afterAll(async () => { + test.setTimeout(60_000); + await restoreLazyLibrarianConfig(); + }); + + test.beforeEach(async ({ api }) => { + await setSearchEnabled(api, false); + + const createdInstance = await ( + await api.arr.createInstance('lazylibrarian', { + name: 'E2E live lazylibrarian sabnzbd', + url: 'http://127.0.0.1:5299', + apiKey: '0000000000000000000000000000e2e3', + version: 1, + enabled: true, + }) + ).json(); + expect(createdInstance.id, 'createInstance').toBeTruthy(); + instanceId = createdInstance.id; + + clientId = await createSabnzbdDownloadClient(api, sabnzbd); + await pinQueueCleanerSchedule(api); + }); + + test.afterEach(async ({ api }) => { + await teardownLiveInstance(api, { instanceId, clientId }); + instanceId = undefined; + clientId = undefined; + + await resetLibrary(books); + await sabnzbd.clearAll(); + await api.general.purgeStrikes(); + }); + + test('a failed SABnzbd job is removed and strikes DownloadFailed', async ({ api }) => { + test.setTimeout(300_000); + + const book = claimBookById(books, BOOK_ID); + const snatched = await snatchBookViaSabnzbd(book); + + // The core assumption this spec exists to check: LazyLibrarian records the SABnzbd + // nzo_id as the DownloadID, the same way it records a torrent hash. + const { queue, history } = await sabnzbd.findJob(snatched.downloadId); + expect(queue?.nzo_id ?? history?.nzo_id, 'LazyLibrarian must record the SABnzbd nzo_id as the DownloadID').toBe( + snatched.downloadId, + ); + + // A missing article can sit out SABnzbd's 60s news server timeout before the job fails. + await sabnzbd.waitForHistoryStatus(snatched.downloadId, 'Failed', 120_000); + + expect((await api.jobs.trigger('QueueCleaner')).status).toBeLessThan(300); + + await expect + .poll(async () => (await sabnzbd.findJob(snatched.downloadId)).history, { timeout: REMOVAL_TIMEOUT }) + .toBeUndefined(); + + const token = adminTokens().accessToken; + const event = await findEventWithDeleteReason(token, snatched.downloadId, 'DownloadFailed', EVENT_TIMEOUT); + expect(event, 'no DownloadFailed event for the SABnzbd job').toBeTruthy(); + }); +}); diff --git a/e2e/tests/malware-blocker/malware-blocker.spec.ts b/e2e/tests/malware-blocker/malware-blocker.spec.ts index 0ac460dd..78a97aa3 100644 --- a/e2e/tests/malware-blocker/malware-blocker.spec.ts +++ b/e2e/tests/malware-blocker/malware-blocker.spec.ts @@ -18,7 +18,7 @@ import { WireMockClient, Mapping } from '../helpers/mocks/wiremock-client'; import { arrHealthStub, arrCatchAllMapping, readDeletes } from '../helpers/mocks/arr-stubs'; import { TEST_CONFIG, farFutureCron } from '../helpers/test-config'; import { mkdirShared, writeFileShared } from '../helpers/shared-volume'; -import { buildHubConnection, waitForEvent } from '../helpers/api/signalr'; +import { findEventWithDeleteReason } from '../helpers/api/signalr'; import { appLogsSince } from '../helpers/test-lifecycle'; /** @@ -231,7 +231,6 @@ function runClientScenarios( enabled: true, name: `${driver.typeName} malware-blocker e2e`, typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', @@ -359,35 +358,6 @@ function runClientScenarios( * Every file matches the blocklist and `DeleteIfAnyFileBlocked` stays off. * The suite pins removal with DeleteReason.AllFilesBlocked on every client. */ -async function findEventWithDeleteReason( - token: string, - itemHash: string, - deleteReason: string, - timeoutMs: number, -): Promise | undefined> { - const connection = buildHubConnection({ accessToken: token, hubUrl: '/api/hubs/app' }); - await connection.start(); - - try { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const received = waitForEvent>>(connection, 'EventsReceived'); - await connection.invoke('GetRecentEvents', 20); - const events = await received; - const match = events.find( - (e) => String(e.itemHash).toLowerCase() === itemHash.toLowerCase() && e.deleteReason === deleteReason, - ); - if (match) { - return match; - } - await new Promise((r) => setTimeout(r, 500)); - } - return undefined; - } finally { - await connection.stop(); - } -} - test.describe.serial('Malware blocker — AllFilesBlocked', () => { let token: string; const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); @@ -460,7 +430,6 @@ function runAllFilesBlockedScenario( enabled: true, name: `${driver.typeName} malware-blocker all-files-blocked e2e`, typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', @@ -617,7 +586,6 @@ function runAllFilesBlockedKeepPrivateScenario( enabled: true, name: `${driver.typeName} malware-blocker all-files-blocked-private e2e`, typeName: driver.typeName, - type: 'Torrent', host: driver.cleanuparrHost, username: driver.username ?? '', password: driver.password ?? '', diff --git a/e2e/tests/malware-blocker/sabnzbd-malware-blocker.spec.ts b/e2e/tests/malware-blocker/sabnzbd-malware-blocker.spec.ts new file mode 100644 index 00000000..03cca10f --- /dev/null +++ b/e2e/tests/malware-blocker/sabnzbd-malware-blocker.spec.ts @@ -0,0 +1,211 @@ +import { test, expect } from '@playwright/test'; +import { join } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + deleteDownloadClient, + listDownloadClients, + createArrInstance, + deleteArrInstance, + getMalwareBlockerConfig, + updateMalwareBlockerConfig, + triggerJob, +} from '../helpers/app-api'; +import { SabnzbdDriver } from '../helpers/usenet-clients/sabnzbd'; +import { WireMockClient, Mapping } from '../helpers/mocks/wiremock-client'; +import { TEST_CONFIG, farFutureCron } from '../helpers/test-config'; +import { writeFileShared } from '../helpers/shared-volume'; + +/** + * SABnzbd has no way to stop fetching individual files mid-job (the whole NZB is + * already pulled from Usenet by the time Cleanuparr can act), so MalwareBlocker + * scans a *completed* job's files on disk instead, exactly like a fast post-download + * scan. This covers the two outcomes: a blocked file is deleted and the arr is told to + * remove the job, and a clean job is left alone. + */ + +const APP_DOWNLOADS = '/e2e-downloads'; +const BLOCKLIST_REL_PATH = 'sab-malware-blocker/blocklist.txt'; +const HOST_DOWNLOADS = join(__dirname, '..', '..', 'test-data', 'downloads'); +const APP_BLOCKLIST_PATH = `${APP_DOWNLOADS}/${BLOCKLIST_REL_PATH}`; +const POLL_TIMEOUT_MS = 30_000; + +function arrHealthMapping(): Mapping { + return { + request: { method: 'GET', urlPathPattern: '/api/v[0-9]+/system/status' }, + response: { status: 200, jsonBody: { version: '4.0.0.0', appName: 'Sonarr' } }, + priority: 1, + }; +} + +function arrQueueMapping(downloadId: string, id: number): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/queue' }, + response: { + status: 200, + jsonBody: { + page: 1, pageSize: 10, totalRecords: 1, + records: [{ + id, downloadId, title: downloadId, protocol: 'usenet', + seriesId: 1, episodeId: 1, seasonNumber: 1, status: 'downloading', + trackedDownloadStatus: 'ok', trackedDownloadState: 'downloading', statusMessages: [], sizeLeft: 0, + }], + }, + }, + priority: 1, + }; +} + +function arrCatchAllMapping(): Mapping { + return { request: { method: 'ANY', urlPathPattern: '/api/v[0-9]+/.*' }, response: { status: 200, jsonBody: {} }, priority: 100 }; +} + +async function readDeletes(arr: WireMockClient, id: number): Promise { + return (await arr.findRequests({ method: 'DELETE', urlPattern: `/api/v3/queue/${id}.*` })).length > 0; +} + +test.describe.serial('SABnzbd live: MalwareBlocker', () => { + const driver = new SabnzbdDriver(); + const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); + let token: string; + let clientId: string; + let sonarrId: string; + + test.beforeAll(async () => { + test.setTimeout(60_000); + token = await loginAndGetToken(); + await driver.ready(); + await driver.clearAll(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + writeFileShared(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n'); + + const createRes = await createDownloadClient(token, { + enabled: true, + name: 'SABnzbd malware-blocker e2e', + typeName: 'Sabnzbd', + host: driver.cleanuparrHost, + apiKey: driver.apiKey, + // SAB reports its own /downloads view in `storage`; the app only sees that + // same tree under /e2e-downloads/sabnzbd, so RemapAndTrim needs this pair + // to resolve the path before it can scan the completed job's files. + downloadDirectorySource: '/downloads', + downloadDirectoryTarget: '/e2e-downloads/sabnzbd', + }); + expect(createRes.status).toBeLessThan(300); + clientId = (await createRes.json()).id; + + await arr.waitReady(); + await arr.resetAll(); + await arr.stubMany([arrHealthMapping(), arrCatchAllMapping()]); + + const sonarrRes = await createArrInstance(token, 'sonarr', { + name: 'SABnzbd MalwareBlocker Stub', url: TEST_CONFIG.mocks.arrUrl, apiKey: 'sab-mb-e2e', version: 4, + }); + expect(sonarrRes.status).toBe(201); + sonarrId = (await sonarrRes.json()).id; + + const current = await (await getMalwareBlockerConfig(token)).json(); + await updateMalwareBlockerConfig(token, { + ...current, + enabled: true, + useAdvancedScheduling: true, + cronExpression: farFutureCron(), + // Every scenario below is a single-file job, so "any file blocked" and "all + // files blocked" are the same outcome; false keeps DeleteUnwantedFiles (the + // per-file disk delete, SAB's only way to act on a completed download) in + // play for the mixed-job case a future spec could add. + deleteIfAnyFileBlocked: false, + ignorePrivate: false, + deletePrivate: true, + processNoContentId: false, + sonarr: { ...(current.sonarr ?? {}), enabled: true, blocklistPath: APP_BLOCKLIST_PATH, blocklistType: 'Blacklist' }, + }); + }); + + test.afterAll(async () => { + if (clientId) { + await deleteDownloadClient(token, clientId).catch(() => undefined); + } + if (sonarrId) { + await deleteArrInstance(token, 'sonarr', sonarrId).catch(() => undefined); + } + await driver.clearAll().catch(() => undefined); + }); + + test('a blocked file in a completed job is deleted from disk; the clean file and the job survive', async () => { + test.setTimeout(90_000); + const dirName = `mb-blocked-${Date.now().toString(36)}`; + driver.writeArticleFile(dirName, 'movie.mkv', 32_768); + driver.writeArticleFile(dirName, 'installer.exe', 4_096); + const nzoId = await driver.addWorkingNzbDir(dirName, 'e2e-mb'); + const historySlot = await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + await arr.resetAll(); + await arr.stubMany([arrHealthMapping(), arrQueueMapping(nzoId, 7001), arrCatchAllMapping()]); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + // SABnzbd can't stop a job from fetching a file mid-download (the whole NZB is + // already pulled from Usenet), so the one way it can act on a blocked file in a + // completed, *mixed* job is to delete just that file from its own storage: + // deleteIfAnyFileBlocked is off, so the job itself is not reported to the arr. + const { existsSync, readdirSync } = await import('node:fs'); + const storageHostPath = historySlot.storage.replace('/downloads', join(HOST_DOWNLOADS, 'sabnzbd')); + await expect(async () => { + const remaining = existsSync(storageHostPath) ? readdirSync(storageHostPath) : []; + expect(remaining.some((f) => f.endsWith('.exe')), `expected installer.exe gone, saw ${remaining.join(', ')}`).toBe(false); + expect(remaining.some((f) => f.endsWith('.mkv')), `expected movie.mkv to survive, saw ${remaining.join(', ')}`).toBe(true); + }).toPass({ timeout: 30_000 }); + + expect(await readDeletes(arr, 7001), 'a mixed job must not be reported to the arr when deleteIfAnyFileBlocked is off').toBe(false); + }); + + test('a blocked single-file job is scanned and its file deleted', async () => { + test.setTimeout(90_000); + const fileName = `payload-${Date.now().toString(36)}.exe`; + const nzoId = await driver.addWorkingNzb(fileName, 4_096, 'e2e-mb'); + const historySlot = await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + // The quirk this test guards against: a single-file job's storage names the file + // itself, not its job folder (same as the orphaned-files spec's single-file case). + expect(historySlot.storage, 'the live quirk this test guards against').toMatch(/\.exe$/); + + await arr.resetAll(); + await arr.stubMany([arrHealthMapping(), arrQueueMapping(nzoId, 7003), arrCatchAllMapping()]); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + const { existsSync } = await import('node:fs'); + const storageHostPath = historySlot.storage.replace('/downloads', join(HOST_DOWNLOADS, 'sabnzbd')); + await expect(async () => { + expect(existsSync(storageHostPath), `expected ${fileName} gone`).toBe(false); + }).toPass({ timeout: 30_000 }); + + // A single-file job's one file is its only file, so blocking it blocks every file in + // the job: the arr must be told to drop it, the same as the mixed-job "all blocked" case. + expect(await readDeletes(arr, 7003), 'a fully-blocked job must be reported to the arr').toBe(true); + }); + + test('a clean completed job is left alone', async () => { + test.setTimeout(90_000); + const dirName = `mb-clean-${Date.now().toString(36)}`; + driver.writeArticleFile(dirName, 'movie.mkv', 32_768); + const nzoId = await driver.addWorkingNzbDir(dirName, 'e2e-mb'); + await driver.waitForHistoryStatus(nzoId, 'Completed', 60_000); + + await arr.resetAll(); + await arr.stubMany([arrHealthMapping(), arrQueueMapping(nzoId, 7002), arrCatchAllMapping()]); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + await new Promise((r) => setTimeout(r, 4_000)); + + expect(await readDeletes(arr, 7002)).toBe(false); + }); +}); diff --git a/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts b/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts index d0189d75..31c5218e 100644 --- a/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts +++ b/e2e/tests/queue-cleaner/fractional-sizeleft.spec.ts @@ -48,45 +48,52 @@ test.describe.serial('QueueCleaner — fractional sizeleft from Sonarr v3', () = await mocks.arr.stub(ArrStubs.arrRawQueueStub(QUEUE_BODY)); // The failed import check needs a pattern that matches the status message. - const current = await (await api.queueCleaner.getConfig()).json(); - const qc = await api.queueCleaner.updateConfig({ - ...current, - failedImport: { - ...current.failedImport, - maxStrikes: 3, - patternMode: 'Include', - patterns: ['Unable to import automatically'], - }, - }); - expect(qc.ok, `queue cleaner updateConfig: ${qc.status}`).toBe(true); + const currentQcConfig = await (await api.queueCleaner.getConfig()).json(); + const currentArrConfig = await (await api.arr.getConfig('sonarr')).json(); - // The arr value replaces the 3 strikes above, so one job run is enough. - const cfg = await api.arr.updateConfig('sonarr', { failedImportMaxStrikes: 1 }); - expect(cfg.ok, `arr updateConfig: ${cfg.status}`).toBe(true); - - const created = await api.arr.createInstance('sonarr', { - name: 'sonarr-fractional-sizeleft', - url: TEST_CONFIG.mocks.arrUrl, - apiKey: 'k', - version: 3, - enabled: true, - }); - expect(created.ok, `createInstance: ${created.status}`).toBe(true); - - const trigger = await api.jobs.trigger('QueueCleaner'); - expect(trigger.status).toBeLessThan(300); - - await expect - .poll( - async () => { - const requests = await mocks.arr.findRequests({ - method: 'DELETE', - urlPattern: '/api/v3/queue/.*', - }); - return requests.some((r) => r.url.includes(`/api/v3/queue/${RECORD_ID}`)); + try { + const qc = await api.queueCleaner.updateConfig({ + ...currentQcConfig, + failedImport: { + ...currentQcConfig.failedImport, + maxStrikes: 3, + patternMode: 'Include', + patterns: ['Unable to import automatically'], }, - { timeout: 60_000, intervals: [1_000] }, - ) - .toBe(true); + }); + expect(qc.ok, `queue cleaner updateConfig: ${qc.status}`).toBe(true); + + // The arr value replaces the 3 strikes above, so one job run is enough. + const cfg = await api.arr.updateConfig('sonarr', { failedImportMaxStrikes: 1 }); + expect(cfg.ok, `arr updateConfig: ${cfg.status}`).toBe(true); + + const created = await api.arr.createInstance('sonarr', { + name: 'sonarr-fractional-sizeleft', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'k', + version: 3, + enabled: true, + }); + expect(created.ok, `createInstance: ${created.status}`).toBe(true); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status).toBeLessThan(300); + + await expect + .poll( + async () => { + const requests = await mocks.arr.findRequests({ + method: 'DELETE', + urlPattern: '/api/v3/queue/.*', + }); + return requests.some((r) => r.url.includes(`/api/v3/queue/${RECORD_ID}`)); + }, + { timeout: 60_000, intervals: [1_000] }, + ) + .toBe(true); + } finally { + await api.queueCleaner.updateConfig(currentQcConfig); + await api.arr.updateConfig('sonarr', { failedImportMaxStrikes: currentArrConfig.failedImportMaxStrikes }); + } }); }); diff --git a/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts b/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts index a8864468..09a4ce38 100644 --- a/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts +++ b/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts @@ -1,5 +1,22 @@ import { test, expect, TEST_CONFIG } from '../fixtures/base'; -import { ArrStubs } from '../helpers/mocks'; +import { ArrStubs, DownloadClientStubs } from '../helpers/mocks'; +import type { QueueRecord } from '../helpers/mocks/arr-stubs'; +import { buildDownloadClientPayload } from '../helpers/api/download-client'; +import { findEventWithDeleteReason } from '../helpers/api/signalr'; +import { adminTokens } from '../helpers/test-lifecycle'; + +/** A Sonarr queue record needs a content id (seriesId/episodeId) or QueueCleaner skips it outright. */ +function sabQueueRecord(record: QueueRecord): QueueRecord & { seriesId: number; episodeId: number } { + return { seriesId: 1, episodeId: 1, ...record }; +} + +// Setup resets app state only between spec folders, and every leftover instance reads the same arr mock. +test.afterEach(async ({ api }) => { + const config: { instances?: Array<{ id: string }> } = await (await api.arr.getConfig('sonarr')).json(); + for (const instance of config.instances ?? []) { + await api.arr.deleteInstance('sonarr', instance.id); + } +}); test.describe('QueueCleaner — job execution end-to-end', () => { test('manual trigger is accepted', async ({ api, mocks }) => { @@ -72,3 +89,173 @@ test.describe('QueueCleaner — job execution end-to-end', () => { } }); }); + +// SABnzbd has no live container in this mocked suite, so every case below drives the +// client through wiremock-dlc. QueueCleaner never deletes from the client itself for a +// usenet job: it asks the arr to do it via DELETE /api/v3/queue/{id}?removeFromClient=. +test.describe('QueueCleaner: SABnzbd job execution (mocked)', () => { + async function registerSabClient(api: import('../fixtures/base').CleanuparrApi): Promise { + await api.downloadClient.create( + buildDownloadClientPayload('sabnzbd', { + name: `sab-qc-${Date.now()}`, + host: TEST_CONFIG.mocks.downloadClientUrl, + apiKey: 'e2e-key', + }), + ); + } + + // Runs on failure too, so a failed poll can't leave a SABnzbd client behind for later specs. + test.afterEach(async ({ api }) => { + const list: { clients: Array<{ id: string; name: string }> } = await (await api.downloadClient.list()).json(); + for (const client of list.clients.filter((c) => c.name.startsWith('sab-qc-'))) { + await api.downloadClient.delete(client.id); + } + }); + + test('a failed history job is removed from the arr queue', async ({ api, mocks }) => { + await ArrStubs.applyArrDefaults(mocks.arr); + await mocks.downloadClient.stub(DownloadClientStubs.sabQueueStub([])); + await mocks.downloadClient.stub( + DownloadClientStubs.sabHistoryStub([{ nzo_id: 'SAB-FAILED-1', status: 'Failed', fail_message: 'Download failed' }]), + ); + await mocks.arr.stub( + ArrStubs.arrQueueStub([ + sabQueueRecord({ id: 501, title: 'sab.failed.job', status: 'warning', downloadId: 'SAB-FAILED-1', protocol: 'usenet' }), + ]), + ); + await mocks.arr.stub(ArrStubs.arrQueueDeleteStub()); + + await registerSabClient(api); + await api.arr.createInstance('sonarr', { + name: 'sonarr-sab-qc-failed', url: TEST_CONFIG.mocks.arrUrl, apiKey: 'k', version: 3, enabled: true, + }); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status).toBeLessThan(300); + + await expect(async () => { + const deletes = await mocks.arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/501.*' }); + expect(deletes.length).toBeGreaterThan(0); + }).toPass({ timeout: 15_000 }); + + const event = await findEventWithDeleteReason(adminTokens().accessToken, 'SAB-FAILED-1', 'DownloadFailed', 15_000); + expect(event, 'a DownloadFailed event should have been emitted for the failed SABnzbd job').toBeDefined(); + }); + + test('an in-progress queue job is left in the arr queue', async ({ api, mocks }) => { + await ArrStubs.applyArrDefaults(mocks.arr); + await mocks.downloadClient.stub(DownloadClientStubs.sabQueueStub([{ nzo_id: 'SAB-DOWNLOADING-1', status: 'Downloading' }])); + await mocks.downloadClient.stub(DownloadClientStubs.sabHistoryStub([])); + await mocks.arr.stub( + ArrStubs.arrQueueStub([ + sabQueueRecord({ id: 502, title: 'sab.downloading.job', status: 'downloading', downloadId: 'SAB-DOWNLOADING-1', protocol: 'usenet' }), + ]), + ); + + await registerSabClient(api); + await api.arr.createInstance('sonarr', { + name: 'sonarr-sab-qc-downloading', url: TEST_CONFIG.mocks.arrUrl, apiKey: 'k', version: 3, enabled: true, + }); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status).toBeLessThan(300); + await new Promise((r) => setTimeout(r, 4_000)); + + const deletes = await mocks.arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/502.*' }); + expect(deletes).toEqual([]); + }); + + test('an unreachable client surfaces as not-found rather than a silent removal', async ({ api, mocks }) => { + await ArrStubs.applyArrDefaults(mocks.arr); + await mocks.downloadClient.stub(DownloadClientStubs.sabUnreachableStub()); + await mocks.arr.stub( + ArrStubs.arrQueueStub([ + sabQueueRecord({ id: 503, title: 'sab.unreachable.job', status: 'downloading', downloadId: 'SAB-UNREACHABLE-1', protocol: 'usenet' }), + ]), + ); + + await registerSabClient(api); + await api.arr.createInstance('sonarr', { + name: 'sonarr-sab-qc-unreachable', url: TEST_CONFIG.mocks.arrUrl, apiKey: 'k', version: 3, enabled: true, + }); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status, 'an unreachable client must not fail the whole job run').toBeLessThan(300); + await new Promise((r) => setTimeout(r, 4_000)); + + const deletes = await mocks.arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/503.*' }); + expect(deletes, 'an unreachable client must not be mistaken for "safe to remove"').toEqual([]); + }); + + test('a failed import missing from SABnzbd is removed when skip if not found is off', async ({ api, mocks }) => { + test.setTimeout(120_000); + const recordId = 504; + + await ArrStubs.applyArrDefaults(mocks.arr); + await mocks.downloadClient.stub(DownloadClientStubs.sabQueueStub([])); + await mocks.downloadClient.stub(DownloadClientStubs.sabHistoryStub([])); + await mocks.arr.stub( + ArrStubs.arrRawQueueStub( + JSON.stringify({ + page: 1, + pageSize: 50, + totalRecords: 1, + records: [ + { + id: recordId, + seriesId: 1, + episodeId: 1, + title: 'sab.missing.failed.import', + status: 'completed', + trackedDownloadStatus: 'warning', + trackedDownloadState: 'importFailed', + downloadId: 'SAB-MISSING-1', + protocol: 'usenet', + statusMessages: [{ title: 'sab.missing.failed.import', messages: ['Unable to import automatically'] }], + }, + ], + }), + ), + ); + + await mocks.arr.stub(ArrStubs.arrQueueDeleteStub()); + + const currentQcConfig = await (await api.queueCleaner.getConfig()).json(); + const currentArrConfig = await (await api.arr.getConfig('sonarr')).json(); + + try { + const qc = await api.queueCleaner.updateConfig({ + ...currentQcConfig, + failedImport: { + ...currentQcConfig.failedImport, + maxStrikes: 3, + skipIfNotFoundInClient: false, + patternMode: 'Include', + patterns: ['Unable to import automatically'], + }, + }); + expect(qc.ok, `queue cleaner updateConfig: ${qc.status}`).toBe(true); + + const cfg = await api.arr.updateConfig('sonarr', { failedImportMaxStrikes: 1 }); + expect(cfg.ok, `arr updateConfig: ${cfg.status}`).toBe(true); + + await registerSabClient(api); + await api.arr.createInstance('sonarr', { + name: 'sonarr-sab-qc-missing', url: TEST_CONFIG.mocks.arrUrl, apiKey: 'k', version: 3, enabled: true, + }); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status).toBeLessThan(300); + + await expect + .poll( + async () => (await mocks.arr.findRequests({ method: 'DELETE', urlPattern: `/api/v3/queue/${recordId}.*` })).length, + { timeout: 60_000, intervals: [1_000] }, + ) + .toBeGreaterThan(0); + } finally { + await api.queueCleaner.updateConfig(currentQcConfig); + await api.arr.updateConfig('sonarr', { failedImportMaxStrikes: currentArrConfig.failedImportMaxStrikes }); + } + }); +}); diff --git a/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts b/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts index b173be11..229bd580 100644 --- a/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts +++ b/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts @@ -233,4 +233,17 @@ test.describe('Queue Cleaner UI', () => { await enabled.click(); await expectNoGuardOnLeave(page); }); + + test('Usenet toggle is always checked and cannot be toggled off', async ({ page }) => { + await loginAndGotoSettings(page, 'queue-cleaner'); + await ensureToggle(toggle(page, 'Enabled'), true); + const usenetToggle = toggle(page, 'Remove failed SABnzbd downloads'); + await ensureAccordionExpanded(page, 'Usenet', usenetToggle); + + await expect(usenetToggle).toHaveAttribute('aria-checked', 'true'); + await expect(usenetToggle).toBeDisabled(); + + await usenetToggle.click({ force: true }); + await expect(usenetToggle).toHaveAttribute('aria-checked', 'true'); + }); }); diff --git a/e2e/tests/queue-cleaner/season-pack-paging.spec.ts b/e2e/tests/queue-cleaner/season-pack-paging.spec.ts index e04b3441..a1cb8a88 100644 --- a/e2e/tests/queue-cleaner/season-pack-paging.spec.ts +++ b/e2e/tests/queue-cleaner/season-pack-paging.spec.ts @@ -82,6 +82,14 @@ test.describe('QueueCleaner season pack across queue pages', () => { test('strikes a pack split across pages once per run, not once per page', async ({ api, mocks }) => { test.setTimeout(120_000); + // Earlier specs in this folder leave instances and a lowered strike limit, and each instance would strike the pack again. + const sonarr: { instances?: Array<{ id: string }> } = await (await api.arr.getConfig('sonarr')).json(); + for (const instance of sonarr.instances ?? []) { + await api.arr.deleteInstance('sonarr', instance.id); + } + const arrConfig = await api.arr.updateConfig('sonarr', { failedImportMaxStrikes: -1 }); + expect(arrConfig.ok, `arr updateConfig: ${arrConfig.status}`).toBe(true); + await ArrStubs.applyArrDefaults(mocks.arr); const page1Fillers = Array.from({ length: 199 }, (_, i) => fillerRecord(i + 1)); diff --git a/e2e/tests/signalr/app-hub.api.spec.ts b/e2e/tests/signalr/app-hub.api.spec.ts index 9535e631..372cbffc 100644 --- a/e2e/tests/signalr/app-hub.api.spec.ts +++ b/e2e/tests/signalr/app-hub.api.spec.ts @@ -145,10 +145,7 @@ test.describe('SignalR — app hub', () => { await ArrStubs.applyArrDefaults(mocks.arr); await mocks.arr.stub(ArrStubs.arrRawQueueStub(QUEUE_BODY)); - await mocks.arr.stub({ - request: { method: 'DELETE', urlPathPattern: '/api/v3/queue/.*' }, - response: { status: 200, jsonBody: {} }, - }); + await mocks.arr.stub(ArrStubs.arrQueueDeleteStub()); const current = await (await api.queueCleaner.getConfig()).json(); const qc = await api.queueCleaner.updateConfig({