Add SABnzbd support (#825)

Co-authored-by: Flaminel <flm.marius@gmail.com>
This commit is contained in:
Andrew OvensandFlaminel authored and GitHub committed 2026-10-11 20:33:42 +03:00
1 parent a896e0c400
commit de16a41b6c
210 files changed
+12742 -1377

No files matched your search

@@ -96,6 +96,16 @@ public class DeadTorrentConfigControllerTests : IDisposable
result.ShouldBeOfType<ObjectResult>().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<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Update_NonExistentClient_ReturnsNotFound()
{
@@ -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()
{
@@ -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<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task CreateSeedingRule_SetsMaxInactiveDays()
{
@@ -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<string> { "movies" },
};
// Act
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
// Assert
result.ShouldBeOfType<ObjectResult>().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);
@@ -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<CreateDownloadClientRequest>(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<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _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<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _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<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _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<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _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<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _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<ObjectResult>().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<IDownloadService>();
downloadService.HealthCheckAsync().Returns(new HealthCheckResult { IsHealthy = true });
_downloadServiceFactory.GetDownloadService(Arg.Do<DownloadClientConfig>(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<IDownloadService>();
downloadService.HealthCheckAsync().Returns(new HealthCheckResult { IsHealthy = true });
_downloadServiceFactory.GetDownloadService(Arg.Do<DownloadClientConfig>(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<ObjectResult>().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<ObjectResult>().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<ObjectResult>().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"),
};
}
@@ -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<DownloadClientTypeName>().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",
@@ -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",
};
@@ -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
@@ -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))
@@ -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);
@@ -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,
@@ -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,
};
}
@@ -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,
@@ -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,
@@ -0,0 +1,27 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Responses;
/// <summary>
/// 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.
/// </summary>
public sealed record DownloadClientTypeResponse
{
public required DownloadClientTypeName TypeName { get; init; }
public required List<DownloadClientAuthField> AuthFields { get; init; }
public required List<DownloadClientCapability> Capabilities { get; init; }
public static DownloadClientTypeResponse From(DownloadClientTypeName typeName) => new()
{
TypeName = typeName,
AuthFields = Enum.GetValues<DownloadClientAuthField>()
.Where(field => typeName.SupportsAuthField(field))
.ToList(),
Capabilities = Enum.GetValues<DownloadClientCapability>()
.Where(capability => typeName.SupportsCapability(capability))
.ToList(),
};
}
@@ -145,6 +145,16 @@ public sealed class DownloadClientController : ControllerBase
}
}
[HttpGet("download_client/types")]
public IActionResult GetDownloadClientTypes()
{
List<DownloadClientTypeResponse> types = EnumSentinel.SelectableValues<DownloadClientTypeName>()
.Select(DownloadClientTypeResponse.From)
.ToList();
return Ok(new { types });
}
[HttpPost("download_client/test")]
public async Task<IActionResult> 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();
@@ -0,0 +1,29 @@
namespace Cleanuparr.Domain.Entities;
/// <summary>
/// Universal abstraction for a download item across all download clients, torrent or usenet.
/// </summary>
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; }
/// <summary>
/// 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.
/// </summary>
/// <param name="ignoredDownloads">List of patterns to check against</param>
/// <returns>True if the item matches any ignore pattern</returns>
bool IsIgnored(IReadOnlyList<string> ignoredDownloads);
}
@@ -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.
/// </summary>
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; }
/// <summary>
/// Whether the download client has this item stopped or paused.
/// </summary>
bool IsStopped { get; }
bool IsDownloading();
double Ratio { get; }
/// <summary>
@@ -47,10 +44,6 @@ public interface ITorrentItemWrapper
DateTime? LastActivityTime { get; }
string? Category { get; set; }
string SavePath { get; }
/// <summary>
/// Tracker domains extracted from all trackers associated with this torrent.
/// Used for tracker-based seeding rule matching.
@@ -63,20 +56,5 @@ public interface ITorrentItemWrapper
/// </summary>
IReadOnlyList<string> Tags { get; }
/// <summary>
/// Whether the download client has this torrent stopped or paused.
/// </summary>
bool IsStopped { get; }
bool IsDownloading();
bool IsStalled();
/// <summary>
/// Determines if this torrent should be ignored based on the provided patterns.
/// Checks if any pattern matches the torrent name, hash, or tracker.
/// </summary>
/// <param name="ignoredDownloads">List of patterns to check against</param>
/// <returns>True if the torrent matches any ignore pattern</returns>
bool IsIgnored(IReadOnlyList<string> ignoredDownloads);
}
@@ -24,5 +24,9 @@ public sealed record LazyLibrarianQueueItem
/// </summary>
public required LazyLibrarianOrigin Origin { get; init; }
public bool WasAdoptedByLazyLibrarian => Origin is not LazyLibrarianOrigin.New;
/// <summary>
/// LazyLibrarian records an origin only for torrent snatches; its usenet path never sets one.
/// </summary>
public bool WasAdoptedByLazyLibrarian =>
Source.ClientType() is DownloadClientType.Torrent && Origin is not LazyLibrarianOrigin.New;
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// The error envelope SABnzbd can return on any call. Read responses omit <c>status</c> on success,
/// so <see cref="Status"/> signals an error when <c>false</c> and nothing otherwise.
/// </summary>
public sealed record SabnzbdActionResponse
{
[JsonPropertyName("status")]
public bool? Status { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// The <c>config</c> payload of SABnzbd's <c>get_config</c> API response.
/// </summary>
public sealed record SabnzbdConfigData
{
[JsonPropertyName("misc")]
public SabnzbdMiscConfig? Misc { get; init; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// The response for SABnzbd's <c>mode=get_config</c> API call.
/// </summary>
public sealed record SabnzbdGetConfigResponse
{
[JsonPropertyName("config")]
public SabnzbdConfigData? Config { get; init; }
}
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
public sealed record SabnzbdHistoryData
{
[JsonPropertyName("slots")]
public IReadOnlyList<SabnzbdHistorySlot> Slots { get; init; } = [];
}
@@ -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; }
}
@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// A single job that has left the SABnzbd queue, successfully or not.
/// </summary>
public sealed record SabnzbdHistorySlot
{
[JsonPropertyName("nzo_id")]
public string? NzoId { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// "Completed" or "Failed" once post-processing (verify/repair/extract/move) finishes; until then one of
/// Queued/QuickCheck/Verifying/Repairing/Fetching/Extracting/Moving/Running.
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; init; }
/// <summary>
/// The final output directory on disk.
/// </summary>
[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; }
}
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// The subset of SABnzbd's <c>misc</c> config section Cleanuparr reads.
/// </summary>
public sealed record SabnzbdMiscConfig
{
/// <summary>
/// The folder SABnzbd downloads in-progress jobs into, before they move to <c>complete_dir</c>.
/// </summary>
[JsonPropertyName("download_dir")]
public string? DownloadDir { get; init; }
/// <summary>
/// The folder SABnzbd moves a job into once it finishes downloading and post-processing.
/// </summary>
[JsonPropertyName("complete_dir")]
public string? CompleteDir { get; init; }
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// The SABnzbd queue. Download speed is reported for the server as a whole, not per job.
/// </summary>
public sealed record SabnzbdQueueData
{
[JsonPropertyName("kbpersec")]
public double KbPerSec { get; init; }
[JsonPropertyName("paused")]
public bool Paused { get; init; }
[JsonPropertyName("slots")]
public IReadOnlyList<SabnzbdQueueSlot> Slots { get; init; } = [];
}
@@ -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; }
}
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Entities.Sabnzbd;
/// <summary>
/// A single job in the SABnzbd queue (not yet finished).
/// </summary>
public sealed record SabnzbdQueueSlot
{
[JsonPropertyName("nzo_id")]
public string? NzoId { get; init; }
[JsonPropertyName("filename")]
public string? Filename { get; init; }
/// <summary>
/// For example "Downloading", "Paused", "Queued", "Checking", "Extracting", "Verifying", "Repairing", "Moving", "Fetching", "QuickCheck".
/// </summary>
[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; }
}
@@ -12,4 +12,5 @@ public enum DeleteReason
AllFilesSkippedByQBit,
AllFilesBlocked,
AtLeastOneFileBlocked,
DownloadFailed,
}
@@ -0,0 +1,11 @@
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// A credential field a download client's connection form can ask for.
/// </summary>
public enum DownloadClientAuthField
{
Username,
Password,
ApiKey,
}
@@ -0,0 +1,48 @@
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// A capability a download client service may implement, mirrored by one of the
/// <c>I*Capable</c> interfaces under <c>Infrastructure/Features/DownloadClient</c>.
/// </summary>
public enum DownloadClientCapability
{
/// <summary>
/// Can report whether an *arr queue item should be removed. Backs <c>IQueueCheckCapable</c>.
/// </summary>
QueueCheck,
/// <summary>
/// Can block unwanted files from completing. Backs <c>IFileBlockingCapable</c>.
/// </summary>
FileBlocking,
/// <summary>
/// Can report the on-disk paths its downloads claim. Backs <c>IOrphanClaimsCapable</c>.
/// </summary>
OrphanClaims,
/// <summary>
/// Downloads seed, so seeding rules apply. Backs <c>ISeedingCleanupCapable</c>.
/// </summary>
SeedingCleanup,
/// <summary>
/// Files can be checked for hardlinks, so unlinked downloads can be moved. Backs <c>IUnlinkedCapable</c>.
/// </summary>
Unlinked,
/// <summary>
/// Seeding rules can filter by tag/label. Backs <c>ITagFilterable</c> on the client's seeding rule type.
/// </summary>
TagFiltering,
/// <summary>
/// Seeding rules can filter by minimum seeders. Backs <c>ISeedersFilterable</c> on the client's seeding rule type.
/// </summary>
SeedersFiltering,
/// <summary>
/// Reports a seeder count, so dead-torrent detection applies. Backs <c>IDeadTorrentCapable</c>.
/// </summary>
DeadTorrent,
}
@@ -7,6 +7,7 @@ public enum DownloadClientTypeName
Transmission,
uTorrent,
rTorrent,
Sabnzbd,
/// <summary>
/// Text this build does not know.
@@ -0,0 +1,90 @@
namespace Cleanuparr.Domain.Enums;
public static class DownloadClientTypeNameExtensions
{
private static readonly Dictionary<DownloadClientTypeName, DownloadClientAuthField[]> 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],
};
/// <summary>
/// Whether the client type accepts this credential field.
/// </summary>
public static bool SupportsAuthField(this DownloadClientTypeName typeName, DownloadClientAuthField field) =>
AuthFields.TryGetValue(typeName, out var fields) && fields.Contains(field);
/// <summary>
/// Maps a client type name to its download protocol, so callers never pass this independently.
/// </summary>
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"),
};
/// <summary>
/// True when the field is the client's only credential, so leaving it blank means it cannot authenticate at all.
/// </summary>
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,
];
/// <summary>
/// Every torrent client reports a seeder count and supports min-seeders filtering, except rTorrent.
/// </summary>
private static readonly DownloadClientCapability[] SeedersCapableTorrentCapabilities =
[
..BaseTorrentCapabilities,
DownloadClientCapability.SeedersFiltering,
DownloadClientCapability.DeadTorrent,
];
private static readonly Dictionary<DownloadClientTypeName, DownloadClientCapability[]> Capabilities = new()
{
[DownloadClientTypeName.qBittorrent] =
[
..SeedersCapableTorrentCapabilities,
DownloadClientCapability.TagFiltering,
],
[DownloadClientTypeName.Deluge] = SeedersCapableTorrentCapabilities,
[DownloadClientTypeName.Transmission] =
[
..SeedersCapableTorrentCapabilities,
DownloadClientCapability.TagFiltering,
],
[DownloadClientTypeName.uTorrent] = SeedersCapableTorrentCapabilities,
[DownloadClientTypeName.rTorrent] = BaseTorrentCapabilities,
[DownloadClientTypeName.Sabnzbd] = UsenetCapabilities,
};
/// <summary>
/// Whether the client type's service implements the capability's <c>I*Capable</c> interface.
/// </summary>
public static bool SupportsCapability(this DownloadClientTypeName typeName, DownloadClientCapability capability) =>
Capabilities.TryGetValue(typeName, out var capabilities) && capabilities.Contains(capability);
}
@@ -4,7 +4,7 @@ namespace Cleanuparr.Domain.Enums;
/// <summary>
/// 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.
/// </summary>
[JsonConverter(typeof(TolerantEnumConverter<LazyLibrarianDownloadMode>))]
public enum LazyLibrarianDownloadMode
@@ -2,21 +2,24 @@ namespace Cleanuparr.Domain.Enums;
public static class LazyLibrarianSourceExtensions
{
private static readonly HashSet<LazyLibrarianSource> TorrentClients =
[
LazyLibrarianSource.QBittorrent,
LazyLibrarianSource.Transmission,
LazyLibrarianSource.DelugeWebUi,
LazyLibrarianSource.DelugeRpc,
LazyLibrarianSource.UTorrent,
LazyLibrarianSource.RTorrent,
];
private static readonly Dictionary<LazyLibrarianSource, DownloadClientType> 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,
};
/// <summary>
/// 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.
/// </summary>
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);
@@ -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)
{
}
}
@@ -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);
@@ -48,7 +48,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// 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<DelugeServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -112,7 +112,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -135,7 +135,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -202,7 +202,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
}
@@ -314,7 +314,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -354,7 +354,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -452,7 +452,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), true)
@@ -473,7 +473,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.DeleteTorrents(Arg.Any<List<string>>(), true)
@@ -494,7 +494,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.DeleteTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")), false)
@@ -522,7 +522,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
DelugeService sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.PauseTorrents(Arg.Is<List<string>>(h => h.Contains("test-hash")))
@@ -543,7 +543,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
DelugeService sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.PauseTorrents(Arg.Any<List<string>>())
@@ -563,7 +563,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
DelugeService sut = _fixture.CreateSut();
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("TEST-HASH");
mockTorrent.DownloadId.Returns("TEST-HASH");
_fixture.ClientWrapper
.PauseTorrents(Arg.Any<List<string>>())
@@ -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;
/// <summary>
/// Guards the capability map in <see cref="DownloadClientTypeNameExtensions"/> against drifting from what
/// each client's service actually implements: a new client type, or a service that gains or drops an
/// <c>I*Capable</c> interface without updating the map, fails here instead of silently falling out of
/// whichever job narrows via <c>OfType&lt;T&gt;()</c>.
/// </summary>
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<IMemoryCache>(_memoryCache);
services.AddSingleton(Substitute.For<ILogger<QBitService>>());
services.AddSingleton(Substitute.For<ILogger<DelugeService>>());
services.AddSingleton(Substitute.For<ILogger<TransmissionService>>());
services.AddSingleton(Substitute.For<ILogger<UTorrentService>>());
services.AddSingleton(Substitute.For<ILogger<RTorrentService>>());
services.AddSingleton(Substitute.For<ILogger<SabnzbdService>>());
services.AddSingleton(Substitute.For<ILogger<DownloadServiceFactory>>());
services.AddSingleton(Substitute.For<IFilenameEvaluator>());
services.AddSingleton(Substitute.For<IStriker>());
services.AddSingleton(Substitute.For<IDryRunInterceptor>());
services.AddSingleton(Substitute.For<IHardLinkFileService>());
var httpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
httpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(new HttpClient());
services.AddSingleton(httpClientProvider);
services.AddSingleton(Substitute.For<IQueueRuleEvaluator>());
services.AddSingleton(Substitute.For<IQueueRuleManager>());
services.AddSingleton(Substitute.For<ISeedingRuleEvaluator>());
services.AddLogging();
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var eventsContext = new EventsContext(eventsContextOptions);
IEventNotifier eventNotifier = Substitute.For<IEventNotifier>();
services.AddSingleton<IEventPublisher>(new EventPublisher(
eventsContext,
eventNotifier,
Substitute.For<ILogger<EventPublisher>>(),
Substitute.For<INotificationPublisher>(),
Substitute.For<IDryRunInterceptor>(),
new SqliteDatabaseProvider(),
TimeProvider.System));
var scopeFactory = Substitute.For<IServiceScopeFactory>();
services.AddSingleton<IBlocklistProvider>(new BlocklistProvider(
Substitute.For<ILogger<BlocklistProvider>>(),
scopeFactory,
_memoryCache,
TimeProvider.System));
services.AddSingleton(TimeProvider.System);
_serviceProvider = services.BuildServiceProvider();
_factory = new DownloadServiceFactory(Substitute.For<ILogger<DownloadServiceFactory>>(), _serviceProvider);
}
public void Dispose()
{
_memoryCache.Dispose();
}
public static IEnumerable<object[]> ClientTypeNames() =>
EnumSentinel.SelectableValues<DownloadClientTypeName>().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}");
}
/// <summary>
/// Maps a torrent client type to the <c>ISeedingRule</c> type it persists, so the capability map can be
/// checked against what that rule type actually implements. <c>Sabnzbd</c> has no seeding rule type and
/// is covered separately.
/// </summary>
private static readonly Dictionary<DownloadClientTypeName, Type> 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<object[]> 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<DownloadClientTypeName>())
{
bool supportsAny = Enum.GetValues<DownloadClientCapability>().Any(capability => typeName.SupportsCapability(capability));
supportsAny.ShouldBeTrue($"{typeName} is missing from the DownloadClientTypeNameExtensions capability map");
}
}
}
@@ -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<ILogger<DelugeService>>());
services.AddSingleton(Substitute.For<ILogger<TransmissionService>>());
services.AddSingleton(Substitute.For<ILogger<UTorrentService>>());
services.AddSingleton(Substitute.For<ILogger<SabnzbdService>>());
services.AddSingleton(Substitute.For<IFilenameEvaluator>());
services.AddSingleton(Substitute.For<IStriker>());
@@ -177,6 +179,20 @@ public class DownloadServiceFactoryTests : IDisposable
service.ShouldBeOfType<RTorrentService>();
}
[Fact]
public void GetDownloadService_Sabnzbd_ReturnsSabnzbdService()
{
// Arrange
var config = CreateClientConfig(DownloadClientTypeName.Sabnzbd);
// Act
var service = _factory.GetDownloadService(config);
// Assert
service.ShouldNotBeNull();
service.ShouldBeOfType<SabnzbdService>();
}
[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)
{
@@ -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);
@@ -64,7 +64,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// 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<QBitServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -229,7 +229,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
result[0].DownloadId.ShouldBe("hash2");
}
[Fact]
@@ -422,7 +422,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -481,7 +481,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
private static ITorrentItemWrapper CreateTorrentWithSeederCount(string hash, int? seederCount)
{
var torrent = Substitute.For<ITorrentItemWrapper>();
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<QBitServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
result[0].DownloadId.ShouldBe("hash2");
}
[Fact]
@@ -964,7 +964,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -1068,7 +1068,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains(hash)), true)
@@ -1089,7 +1089,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>())
@@ -1117,7 +1117,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
QBitService sut = _fixture.CreateSut();
const string hash = "test-hash";
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.PauseAsync(Arg.Is<IEnumerable<string>>(h => h.Contains(hash)))
@@ -1137,7 +1137,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
QBitService sut = _fixture.CreateSut();
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
_fixture.ClientWrapper
.PauseAsync(Arg.Any<IEnumerable<string>>())
@@ -1197,7 +1197,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.DownloadId.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
@@ -1216,7 +1216,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.DownloadId.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
@@ -1235,7 +1235,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.DownloadId.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
@@ -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<QBitServiceFixture>
// Assert
result.Found.ShouldBeTrue();
result.Torrent!.TrackerDomains.ShouldBeEmpty();
((ITorrentItemWrapper)result.Item!).TrackerDomains.ShouldBeEmpty();
}
}
@@ -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]
@@ -57,7 +57,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// 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<RTorrentServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("HASH1");
result[0].DownloadId.ShouldBe("HASH1");
}
[Fact]
@@ -295,7 +295,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("HASH1");
result[0].DownloadId.ShouldBe("HASH1");
}
[Fact]
@@ -334,7 +334,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var sut = _fixture.CreateSut();
var hash = "lowercase";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
mockTorrent.SavePath.Returns("/test/path");
_fixture.ClientWrapper
@@ -375,7 +375,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
var sut = _fixture.CreateSut(config);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("hash");
mockTorrent.DownloadId.Returns("hash");
mockTorrent.SavePath.Returns("/downloads/seed-rule-tracker");
try
@@ -408,7 +408,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
RTorrentService sut = _fixture.CreateSut();
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("lowercase");
mockTorrent.DownloadId.Returns("lowercase");
mockTorrent.SavePath.Returns("/test/path");
_fixture.ClientWrapper
@@ -429,7 +429,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
// Arrange
RTorrentService sut = _fixture.CreateSut();
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("LOWERCASE");
mockTorrent.DownloadId.Returns("LOWERCASE");
mockTorrent.SavePath.Returns("/test/path");
_fixture.ClientWrapper
@@ -454,7 +454,7 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await File.WriteAllTextAsync(Path.Combine(savePath, "file.mkv"), "content");
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("HASH1");
mockTorrent.DownloadId.Returns("HASH1");
mockTorrent.SavePath.Returns(savePath);
_fixture.ClientWrapper
@@ -774,6 +774,91 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
}
}
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<string>());
}
[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<IOException>(() => sut.DeleteDownload(torrent, true));
await _fixture.ClientWrapper.Received(1).StopTorrentAsync("TEST-HASH");
await _fixture.ClientWrapper.DidNotReceive().DeleteTorrentAsync(Arg.Any<string>());
}
finally
{
File.SetUnixFileMode(tempDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
Directory.Delete(tempDir, true);
}
}
}
public class BlockUnwantedFilesAsyncScenarios : RTorrentServiceTests
{
public BlockUnwantedFilesAsyncScenarios(RTorrentServiceFixture fixture) : base(fixture)
@@ -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<SabnzbdClientException>(() => 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<SabnzbdClientException>(() => 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<SabnzbdClientException>(() => 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<SabnzbdClientException>(() => 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<SabnzbdClientException>(() => client.GetQueueAsync());
exception.Message.ShouldContain("SABnzbd request failed for mode 'queue'");
}
}
@@ -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<SabnzbdService> 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<ILogger<SabnzbdService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
ClientWrapper = Substitute.For<ISabnzbdClientWrapper>();
DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(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<DownloadClientConfig>())
.Returns(httpClient);
return new SabnzbdService(
Logger,
FilenameEvaluator,
DryRunInterceptor,
HttpClientProvider,
EventPublisher,
BlocklistProvider,
config,
TimeProvider.System,
ClientWrapper
);
}
public void ResetMocks()
{
SubstituteHelper.ClearPendingArgSpecs();
Logger = Substitute.For<ILogger<SabnzbdService>>();
FilenameEvaluator = Substitute.For<IFilenameEvaluator>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HttpClientProvider = Substitute.For<IDynamicHttpClientProvider>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
ClientWrapper = Substitute.For<ISabnzbdClientWrapper>();
DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}
@@ -282,7 +282,7 @@ public sealed class TorrentListErrorSurfacingTests
QBitService sut = fixture.CreateSut();
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>()).Returns((IReadOnlyList<TorrentInfo>)null!);
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllDownloadsLite());
}
[Fact]
@@ -293,7 +293,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>())
.Returns(new List<TorrentInfo> { new() });
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllDownloadsLite());
}
[Fact]
@@ -304,7 +304,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>())
.Returns(new List<TorrentInfo> { new() { Hash = "hash1" }, new() });
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllDownloadsLite());
}
[Fact]
@@ -461,7 +461,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetStatusForAllTorrents()
.Returns((List<Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus>?)null);
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.DelugeClientException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.DelugeClientException>(() => sut.GetAllDownloadsLite());
}
[Fact]
@@ -472,7 +472,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
.Returns((Transmission.API.RPC.Entity.TransmissionTorrents?)null);
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllDownloadsLite());
}
[Fact]
@@ -486,7 +486,7 @@ public sealed class TorrentListErrorSurfacingTests
Torrents = [new Transmission.API.RPC.Entity.TorrentInfo { HashString = "" }],
});
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => sut.GetAllTorrentsLite());
await Should.ThrowAsync<InvalidOperationException>(() => sut.GetAllDownloadsLite());
}
#endregion
@@ -516,7 +516,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetAllTorrentsAsync()
.Returns(new List<Cleanuparr.Domain.Entities.RTorrent.Response.RTorrentTorrent>());
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
List<IDownloadItem> torrents = await sut.GetAllDownloadsLite();
torrents.ShouldBeEmpty();
}
@@ -529,7 +529,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetStatusForAllTorrents()
.Returns(new List<Cleanuparr.Domain.Entities.Deluge.Response.DownloadStatus>());
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
List<IDownloadItem> torrents = await sut.GetAllDownloadsLite();
torrents.ShouldBeEmpty();
}
@@ -542,7 +542,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetTorrentListAsync(Arg.Any<TorrentListQuery>())
.Returns(new List<TorrentInfo>());
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
List<IDownloadItem> torrents = await sut.GetAllDownloadsLite();
torrents.ShouldBeEmpty();
}
@@ -555,7 +555,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.TorrentGetAsync(Arg.Any<string[]>(), Arg.Any<string?>())
.Returns(new Transmission.API.RPC.Entity.TransmissionTorrents { Torrents = [] });
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
List<IDownloadItem> torrents = await sut.GetAllDownloadsLite();
torrents.ShouldBeEmpty();
}
@@ -568,7 +568,7 @@ public sealed class TorrentListErrorSurfacingTests
fixture.ClientWrapper.GetTorrentsAsync()
.Returns(new List<Cleanuparr.Domain.Entities.UTorrent.Response.UTorrentItem>());
List<ITorrentItemWrapper> torrents = await sut.GetAllTorrentsLite();
List<IDownloadItem> torrents = await sut.GetAllDownloadsLite();
torrents.ShouldBeEmpty();
}
@@ -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);
@@ -49,7 +49,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.Count.ShouldBe(2);
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
foreach (var item in result) { item.DownloadId.ShouldNotBeNull(); }
}
[Fact]
@@ -93,7 +93,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -141,7 +141,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -192,7 +192,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
}
@@ -304,7 +304,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -344,7 +344,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -385,7 +385,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
result[0].DownloadId.ShouldBe("hash2");
}
[Fact]
@@ -407,7 +407,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
result[0].DownloadId.ShouldBe("hash2");
}
}
@@ -39,7 +39,7 @@ public class UTorrentItemWrapperTests
var wrapper = new UTorrentItemWrapper(torrentItem, torrentProperties, TimeProvider.System);
// Act
var result = wrapper.Hash;
var result = wrapper.DownloadId;
// Assert
result.ShouldBe(expectedHash);
@@ -103,7 +103,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -135,7 +135,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Assert
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
result[0].DownloadId.ShouldBe("hash2");
}
}
@@ -247,7 +247,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -287,7 +287,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash1");
result[0].DownloadId.ShouldBe("hash1");
}
[Fact]
@@ -342,7 +342,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), true)
@@ -363,7 +363,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.RemoveTorrentsAsync(Arg.Any<List<string>>(), true)
@@ -384,7 +384,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.RemoveTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")), false)
@@ -412,7 +412,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
UTorrentService sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.StopTorrentsAsync(Arg.Is<List<string>>(h => h.Contains("test-hash")))
@@ -433,7 +433,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
UTorrentService sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns(hash);
mockTorrent.DownloadId.Returns(hash);
_fixture.ClientWrapper
.StopTorrentsAsync(Arg.Any<List<string>>())
@@ -453,7 +453,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
UTorrentService sut = _fixture.CreateSut();
ITorrentItemWrapper mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("TEST-HASH");
mockTorrent.DownloadId.Returns("TEST-HASH");
_fixture.ClientWrapper
.StopTorrentsAsync(Arg.Any<List<string>>())
@@ -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<IStriker>();
_downloadService = Substitute.For<IDownloadService>();
_downloadService = Substitute.For<IDeadTorrentCapable>();
_timeProvider = new FakeTimeProvider();
_clientConfig = new DownloadClientConfig
@@ -77,7 +77,7 @@ public sealed class DeadTorrentServiceTests : IDisposable
DateTimeOffset? addedOn = null)
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns(hash);
torrent.DownloadId.Returns(hash);
torrent.Name.Returns($"Test {hash}");
torrent.Category.Returns(category);
torrent.SeederCount.Returns(seederCount);
@@ -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<ITorrentItemWrapper> torrents)
private IMockDownloadService SetupDownloadService(DownloadClientConfig clientConfig, List<IDownloadItem> torrents)
{
var svc = Substitute.For<IDownloadService>();
var svc = Substitute.For<IMockDownloadService>();
svc.ClientConfig.Returns(clientConfig);
svc.LoginAsync().Returns(Task.CompletedTask);
svc.GetSeedingDownloads().Returns([]);
svc.GetAllTorrentsLite().Returns(torrents);
svc.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<ITorrentItemWrapper>>())
.Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg<IReadOnlyList<ITorrentItemWrapper>>())));
svc.GetAllDownloadsLite().Returns(torrents);
svc.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<IDownloadItem>>())
.Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg<IReadOnlyList<IDownloadItem>>())));
_fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc);
return svc;
}
private static IReadOnlyList<string> BuildDefaultClaimedPaths(IReadOnlyList<ITorrentItemWrapper> torrents)
private static IReadOnlyList<string> BuildDefaultClaimedPaths(IReadOnlyList<IDownloadItem> torrents)
{
HashSet<string> 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<IDownloadService>();
IMockDownloadService svc = Substitute.For<IMockDownloadService>();
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<IDownloadService>();
IMockDownloadService svcA = Substitute.For<IMockDownloadService>();
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<IDownloadService>();
IMockDownloadService svcA = Substitute.For<IMockDownloadService>();
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<IDownloadItem> torrentsA = [DecoyTorrent()];
IMockDownloadService svcA = Substitute.For<IMockDownloadService>();
svcA.ClientConfig.Returns(clientA);
svcA.LoginAsync().Returns(Task.CompletedTask);
svcA.GetSeedingDownloads().Returns([]);
svcA.GetAllDownloadsLite().Returns(torrentsA);
svcA.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<IDownloadItem>>())
.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<IDownloadService>();
IMockDownloadService svc = Substitute.For<IMockDownloadService>();
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();
@@ -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<IDownloadService>();
IMockDownloadService mockDownloadService = Substitute.For<IMockDownloadService>();
mockDownloadService.ClientConfig.Returns(client);
mockDownloadService.LoginAsync().Returns(Task.CompletedTask);
mockDownloadService.GetSeedingDownloads().Returns([]);
mockDownloadService.GetAllTorrentsLite().Returns([]);
mockDownloadService.GetAllDownloadsLite().Returns([]);
mockDownloadService.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<ITorrentItemWrapper>>())
.Returns(Task.FromResult<IReadOnlyList<string>>([]));
@@ -196,7 +196,7 @@ public class DownloadCleanerTests : IDisposable
_fixture.DataContext.SaveChanges();
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("ignored-hash");
mockTorrent.DownloadId.Returns("ignored-hash");
mockTorrent.Name.Returns("Ignored Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(true);
@@ -227,7 +227,7 @@ public class DownloadCleanerTests : IDisposable
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("arr-download-hash");
mockTorrent.DownloadId.Returns("arr-download-hash");
mockTorrent.Name.Returns("Arr Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
@@ -278,7 +278,7 @@ public class DownloadCleanerTests : IDisposable
// Need at least one download for arr processing to occur
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -330,7 +330,7 @@ public class DownloadCleanerTests : IDisposable
ignoredRootDirs: ["/media/library"]);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -377,7 +377,7 @@ public class DownloadCleanerTests : IDisposable
ignoredRootDirs: []);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -439,13 +439,13 @@ public class DownloadCleanerTests : IDisposable
_fixture.DataContext.SaveChanges();
var mockTorrent1 = Substitute.For<ITorrentItemWrapper>();
mockTorrent1.Hash.Returns("test-hash-1");
mockTorrent1.DownloadId.Returns("test-hash-1");
mockTorrent1.Name.Returns("Test Download 1");
mockTorrent1.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent1.Category.Returns("completed");
var mockTorrent2 = Substitute.For<ITorrentItemWrapper>();
mockTorrent2.Hash.Returns("test-hash-2");
mockTorrent2.DownloadId.Returns("test-hash-2");
mockTorrent2.Name.Returns("Test Download 2");
mockTorrent2.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent2.Category.Returns("completed");
@@ -519,7 +519,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).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<ITorrentItemWrapper>();
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
DownloadClientConfig dbClient = _fixture.DataContext.DownloadClients.First();
// rTorrent shape: unlinked-capable, no seeder count.
ISeedingCleanupCapable mockDownloadService = Substitute.For<ISeedingCleanupCapable, IUnlinkedCapable>();
IUnlinkedCapable mockDownloadServiceAsUnlinked = (IUnlinkedCapable)mockDownloadService;
mockDownloadService.ClientConfig.Returns(dbClient);
mockDownloadService.LoginAsync().Returns(Task.CompletedTask);
mockDownloadService.GetSeedingDownloads().Returns([mockTorrent]);
mockDownloadServiceAsUnlinked
.FilterDownloadsToChangeCategoryAsync(Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
.Returns([mockTorrent]);
mockDownloadServiceAsUnlinked.CreateCategoryAsync(Arg.Any<string>()).Returns(Task.CompletedTask);
mockDownloadServiceAsUnlinked
.ChangeCategoryForNoHardLinksAsync(Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
.Returns(Task.CompletedTask);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.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<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).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<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -711,7 +758,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -750,7 +797,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -792,7 +839,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -837,7 +884,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -876,7 +923,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -922,7 +969,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).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<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -1012,7 +1059,7 @@ public class DownloadCleanerTests : IDisposable
TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext, enabled: false);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).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<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("test-hash");
mockTorrent.DownloadId.Returns("test-hash");
mockTorrent.Name.Returns("Test Download");
mockTorrent.IsIgnored(Arg.Any<List<string>>()).Returns(false);
mockTorrent.Category.Returns("completed");
@@ -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
/// <summary>
/// Creates a mock download service that uses the actual DB config (so seeding rules match by ID).
/// </summary>
private static IDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig)
private static IMockDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig)
{
var mock = Substitute.For<IDownloadService>();
var mock = Substitute.For<IMockDownloadService>();
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<ITorrentItemWrapper>>(list =>
list.Count == 1 && list[0].Hash == orphanedHash),
list.Count == 1 && list[0].DownloadId == orphanedHash),
Arg.Any<List<ISeedingRule>>());
}
@@ -157,7 +157,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
// Assert: Only the non-ignored download should be processed
mockDownloadService.Received().FilterDownloadsToBeCleanedAsync(
Arg.Is<List<ITorrentItemWrapper>>(list =>
list.Count == 1 && list[0].Hash == "normal_hash"),
list.Count == 1 && list[0].DownloadId == "normal_hash"),
Arg.Any<List<ISeedingRule>>());
}
@@ -445,7 +445,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
httpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(new HttpClient());
RecordingDownloadService downloadService = new(
Substitute.For<ILogger<DownloadService>>(),
Substitute.For<ILogger<TorrentDownloadService>>(),
Substitute.For<IFilenameEvaluator>(),
Substitute.For<IStriker>(),
_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.
/// </summary>
private sealed class RecordingDownloadService : DownloadService
private sealed class RecordingDownloadService : TorrentDownloadService
{
private readonly List<ITorrentItemWrapper> _seedingDownloads;
public RecordingDownloadService(
ILogger<DownloadService> logger,
ILogger<TorrentDownloadService> logger,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -505,16 +505,16 @@ public class DownloadCleanerIntegrationTests : IDisposable
public List<string> 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<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(
ITorrentItemWrapper torrent) => throw new NotSupportedException();
public override Task<List<ITorrentItemWrapper>> GetAllTorrentsLite() => Task.FromResult(_seedingDownloads);
public override Task<List<IDownloadItem>> GetAllDownloadsLite() =>
Task.FromResult(_seedingDownloads.Cast<IDownloadItem>().ToList());
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<IDownloadItem> downloads) =>
Task.FromResult<IReadOnlyList<string>>([]);
public override Task LoginAsync() => Task.CompletedTask;
public override void Dispose()
{
}
public override Task<HealthCheckResult> HealthCheckAsync() => throw new NotSupportedException();
public override Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(
string hash, IReadOnlyList<string> ignoredDownloads) => throw new NotSupportedException();
string downloadId, IReadOnlyList<string> 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<BlockFilesResult> BlockUnwantedFilesAsync(
string hash, IReadOnlyList<string> ignoredDownloads) => throw new NotSupportedException();
string downloadId, IReadOnlyList<string> ignoredDownloads) => throw new NotSupportedException();
}
private static ITorrentItemWrapper CreateMockTorrentItem(
@@ -564,7 +561,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
bool isStopped = false)
{
var mock = Substitute.For<ITorrentItemWrapper>();
mock.Hash.Returns(hash);
mock.DownloadId.Returns(hash);
mock.Name.Returns(name);
mock.Category.Returns(category);
mock.Ratio.Returns(ratio);
@@ -204,12 +204,12 @@ public class IntegrationTestFixture : IDisposable
/// <summary>
/// Creates a NSubstitute IDownloadService mock with default configuration.
/// </summary>
public IDownloadService CreateMockDownloadService(
public IMockDownloadService CreateMockDownloadService(
string clientName = "Test qBittorrent",
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
DownloadClientType type = DownloadClientType.Torrent)
{
var mock = Substitute.For<IDownloadService>();
var mock = Substitute.For<IMockDownloadService>();
mock.ClientConfig.Returns(new DownloadClientConfig
{
Id = Guid.NewGuid(),
@@ -464,7 +464,7 @@ public class MalwareBlockerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(
Arg.Any<string>(),
@@ -516,7 +516,7 @@ public class MalwareBlockerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([packEp2, otherDownload, packEp3]);
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.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<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
// Client load order is not fixed, so whichever client runs first throws.
int calls = 0;
Func<NSubstitute.Core.CallInfo, Task<BlockFilesResult>> 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<string>(), Arg.Any<List<string>>()).Returns(firstThrows);
var serviceB = _fixture.CreateMockDownloadService("Client B");
serviceB.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>()).Returns(firstThrows);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Is<DownloadClientConfig>(x => x.Id == configA.Id))
.Returns(serviceA);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Is<DownloadClientConfig>(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<ITorrentItemWrapper>();
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
@@ -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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.ShouldRemoveFromQueue(
Arg.Any<InstanceType>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<short>()
).Returns(false);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.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<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService("Test SABnzbd", DownloadClientTypeName.Sabnzbd);
mockDownloadService
.ShouldRemoveFromArrQueueAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new DownloadCheckResult { Found = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.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<bool>(),
Arg.Any<short>()
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenForceImportDefers_SkipsTheFailedImportCheck()
{
@@ -645,7 +714,7 @@ public class QueueCleanerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([queueRecord]);
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new DownloadCheckResult { Found = true, ShouldRemove = false });
@@ -692,7 +761,7 @@ public class QueueCleanerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns(page.ToList());
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new DownloadCheckResult { Found = true, ShouldRemove = false });
@@ -739,7 +808,7 @@ public class QueueCleanerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([packEp2, otherDownload, packEp3]);
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new DownloadCheckResult { Found = true, ShouldRemove = false });
@@ -787,7 +856,7 @@ public class QueueCleanerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([packEp2, otherDownload, packEp3]);
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new DownloadCheckResult { Found = true, ShouldRemove = false });
@@ -838,7 +907,7 @@ public class QueueCleanerTests : IDisposable
.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.Returns([packEp2, otherDownload, packEp3]);
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
IMockDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new DownloadCheckResult
@@ -1700,9 +1769,9 @@ public class QueueCleanerTests : IDisposable
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
ITorrentItemWrapper torrent = Substitute.For<ITorrentItemWrapper>();
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<LazyLibrarianRemovalDecision> 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<IDownloadItem>();
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<ArrInstance>(), Arg.Any<IReadOnlyList<IDownloadService>>(), Arg.Any<IReadOnlyList<string>>())
.Returns([decision]);
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(downloadService);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(ci => ((Func<Task>)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()
{
@@ -0,0 +1,13 @@
using Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// 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.
/// </summary>
public interface IMockDownloadService :
IQueueCheckCapable, IFileBlockingCapable, IOrphanClaimsCapable, ISeedingCleanupCapable, IUnlinkedCapable,
IDeadTorrentCapable
{
}
@@ -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
/// <summary>
/// Creates a mock download service
/// </summary>
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<IDownloadService>();
var mock = Substitute.For<IMockDownloadService>();
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")
});
@@ -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",
@@ -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<IDownloadService>();
IMockDownloadService service = Substitute.For<IMockDownloadService>();
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<LazyLibrarianRemovalDecision> decisions =
@@ -75,12 +76,36 @@ public class LazyLibrarianJobEvaluatorTests
await usenet.DidNotReceive().ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>());
}
[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<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult { Found = false });
usenetClient.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult { Found = false });
// Act
await CreateQueueCleanerEvaluator().EvaluateAsync(_instance, [torrentClient, usenetClient], []);
// Assert
await torrentClient.Received(1).ShouldRemoveFromArrQueueAsync("HASH2", Arg.Any<IReadOnlyList<string>>());
await usenetClient.Received(1).ShouldRemoveFromArrQueueAsync("HASH1", Arg.Any<IReadOnlyList<string>>());
}
[Fact]
public async Task EvaluateAsync_SkipsAnIgnoredDownload()
{
// Arrange
StubQueue(CreateItem());
IDownloadService client = CreateClient();
IMockDownloadService client = CreateClient();
// Act
IReadOnlyList<LazyLibrarianRemovalDecision> decisions =
@@ -96,7 +121,7 @@ public class LazyLibrarianJobEvaluatorTests
{
// Arrange
StubQueue(CreateItem());
IDownloadService client = CreateClient();
IMockDownloadService client = CreateClient();
client.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.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<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult { Found = true, ShouldRemove = false });
@@ -132,18 +157,18 @@ public class LazyLibrarianJobEvaluatorTests
StubQueue(CreateItem());
ITorrentItemWrapper torrent = Substitute.For<ITorrentItemWrapper>();
IDownloadService broken = CreateClient();
IMockDownloadService broken = CreateClient();
broken.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Throws(new Exception("client is down"));
IDownloadService healthy = CreateClient();
IMockDownloadService healthy = CreateClient();
healthy.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.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<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
@@ -178,7 +203,7 @@ public class LazyLibrarianJobEvaluatorTests
ShouldRemove = true,
IsPrivate = isPrivate,
DeleteFromClient = deleteFromClient,
Torrent = Substitute.For<ITorrentItemWrapper>(),
Item = Substitute.For<ITorrentItemWrapper>(),
});
// 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<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new BlockFilesResult
{
@@ -211,7 +236,7 @@ public class LazyLibrarianJobEvaluatorTests
ShouldRemove = true,
IsPrivate = isPrivate,
DeleteReason = DeleteReason.AllFilesBlocked,
Torrent = Substitute.For<ITorrentItemWrapper>(),
Item = Substitute.For<ITorrentItemWrapper>(),
});
// Act
@@ -229,13 +254,13 @@ public class LazyLibrarianJobEvaluatorTests
{
// Arrange
StubQueue(CreateItem("HASH1"), CreateItem("HASH2"));
IDownloadService client = CreateClient();
IMockDownloadService client = CreateClient();
client.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
Found = true,
ShouldRemove = true,
Torrent = Substitute.For<ITorrentItemWrapper>(),
Item = Substitute.For<ITorrentItemWrapper>(),
});
// Act
@@ -162,7 +162,46 @@ public class LazyLibrarianServiceTests
IReadOnlyList<LazyLibrarianQueueItem> 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<LazyLibrarianQueueItem> 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<LazyLibrarianQueueItem> 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<LazyLibrarianQueueItem> items = await _service.GetQueueAsync(_instance);
// Assert
items.ShouldBeEmpty();
}
[Theory]
@@ -84,7 +84,7 @@ public class QueueRuleEvaluatorTests : IDisposable
long downloadedBytes = 0;
var torrent = Substitute.For<ITorrentItemWrapper>();
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<ITorrentItemWrapper>();
torrent.Hash.Returns(hash);
torrent.DownloadId.Returns(hash);
torrent.Name.Returns(name);
torrent.IsPrivate.Returns(isPrivate);
torrent.CompletionPercentage.Returns(completionPercentage);
@@ -373,7 +373,7 @@ public class QueueRuleManagerTests
string size = "100 MB")
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("test-hash");
torrent.DownloadId.Returns("test-hash");
torrent.Name.Returns("Test Torrent");
torrent.IsPrivate.Returns(isPrivate);
torrent.CompletionPercentage.Returns(completionPercentage);
@@ -32,7 +32,7 @@ public sealed class DeadTorrentService : IDeadTorrentService
_striker = striker;
}
public async Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
public async Task ProcessAsync(IDeadTorrentCapable downloadService, List<ITorrentItemWrapper> clientDownloads)
{
DeadTorrentConfig? config = await _dataContext.DeadTorrentConfigs
.AsNoTracking()
@@ -50,7 +50,7 @@ public sealed class DeadTorrentService : IDeadTorrentService
}
List<ITorrentItemWrapper> 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);
@@ -15,5 +15,5 @@ public interface IDeadTorrentService
/// </summary>
/// <param name="downloadService">Download-client service for the current client.</param>
/// <param name="clientDownloads">The client's torrent items to evaluate.</param>
Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
Task ProcessAsync(IDeadTorrentCapable downloadService, List<ITorrentItemWrapper> clientDownloads);
}
@@ -11,5 +11,5 @@ public interface ISeedingRulesCleanupService
/// <summary>
/// Evaluates the seeding rules against the client's downloads and removes those that match.
/// </summary>
Task CleanAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
Task CleanAsync(ISeedingCleanupCapable downloadService, List<ITorrentItemWrapper> clientDownloads);
}
@@ -13,5 +13,5 @@ public interface IUnlinkedDownloadsService
/// Re-categorises downloads with no hard links according to the supplied
/// configuration.
/// </summary>
Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
Task ProcessAsync(IUnlinkedCapable downloadService, List<ITorrentItemWrapper> clientDownloads);
}
@@ -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<bool> TryAddClaimedPathsAsync(IDownloadService downloadService, HashSet<string> claimedPaths)
private async Task<bool> TryAddClaimedPathsAsync(IOrphanClaimsCapable downloadService, HashSet<string> claimedPaths)
{
DownloadClientConfig downloadClient = downloadService.ClientConfig;
List<ITorrentItemWrapper> torrents;
IReadOnlyList<string> clientClaimedPaths;
int torrentCount;
try
{
torrents = await downloadService.GetAllTorrentsLite();
List<IDownloadItem> 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
}
}
/// <summary>
/// Whether any claimed path lives inside the entry, which makes the entry an ancestor to keep.
/// </summary>
private static bool HoldsClaimedPath(string entry, HashSet<string> 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);
@@ -21,7 +21,7 @@ public sealed class SeedingRulesCleanupService : ISeedingRulesCleanupService
_dataContext = dataContext;
}
public async Task CleanAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
public async Task CleanAsync(ISeedingCleanupCapable downloadService, List<ITorrentItemWrapper> clientDownloads)
{
try
{
@@ -25,7 +25,7 @@ public sealed class UnlinkedDownloadsService : IUnlinkedDownloadsService
_hardLinkFileService = hardLinkFileService;
}
public async Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
public async Task ProcessAsync(IUnlinkedCapable downloadService, List<ITorrentItemWrapper> clientDownloads)
{
UnlinkedConfig? unlinkedConfig = await _dataContext.UnlinkedConfigs
.AsNoTracking()
@@ -20,7 +20,7 @@ public sealed record BlockFilesResult
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
/// <summary>
/// The matched torrent, set when <see cref="Found"/> is true.
/// The matched download item, set when <see cref="Found"/> is true.
/// </summary>
public ITorrentItemWrapper? Torrent { get; set; }
public IDownloadItem? Item { get; set; }
}
@@ -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;
}
@@ -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()
{
}
}
}
@@ -10,22 +10,22 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public partial class DelugeService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string downloadId, IReadOnlyList<string> 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;
@@ -26,7 +26,7 @@ public partial class DelugeService
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
public override async Task<List<IDownloadItem>> GetAllDownloadsLite()
{
List<DownloadStatus>? 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<ITorrentItemWrapper> torrents = downloads
List<IDownloadItem> 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
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
BuildClaimedPathsAsync(torrents, async torrent =>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<IDownloadItem> 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<string> relativePaths = [];
ProcessFiles(contents?.Contents, (_, file) =>
{
@@ -66,16 +66,16 @@ public partial class DelugeService
});
/// <inheritdoc/>
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
/// <inheritdoc/>
protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
ChangeLabel(torrent.Hash, targetCategory);
ChangeLabel(torrent.DownloadId, targetCategory);
protected async Task CreateLabel(string name)
{
@@ -9,28 +9,28 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
public partial class DelugeService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList<string> 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)
{
@@ -30,7 +30,7 @@ public sealed record DownloadCheckResult
public bool ChangeCategory { get; set; }
/// <summary>
/// The matched torrent, set when <see cref="Found"/> is true.
/// The matched download item, set when <see cref="Found"/> is true.
/// </summary>
public ITorrentItemWrapper? Torrent { get; set; }
public IDownloadItem? Item { get; set; }
}
@@ -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
/// <summary>
/// Base download client implementation, shared by every protocol.
/// Torrent-only behaviour (seeding, hardlinks, slow/stall rules) lives in <see cref="TorrentDownloadService"/>.
/// </summary>
public abstract class DownloadService : IQueueCheckCapable, IFileBlockingCapable, IOrphanClaimsCapable
{
protected readonly ILogger<DownloadService> _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<DownloadService> 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<bool> IsAltSpeedLimitActiveAsync()
public void Dispose()
{
return Task.FromResult(false);
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Runs the slow check, then the stall check; the first removal wins.
/// </summary>
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);
}
/// <summary>
/// Alt-speed-limit probe for the slow rules.
/// Only qBittorrent and Transmission report that state.
/// </summary>
protected virtual Func<Task<bool>>? 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<HealthCheckResult> HealthCheckAsync();
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc cref="IQueueCheckCapable.ShouldRemoveFromArrQueueAsync"/>
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <inheritdoc cref="IOrphanClaimsCapable.GetAllDownloadsLite"/>
public abstract Task<List<IDownloadItem>> GetAllDownloadsLite();
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
/// <inheritdoc/>
public abstract Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
/// <inheritdoc cref="IOrphanClaimsCapable.GetClaimedPathsAsync"/>
public abstract Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<IDownloadItem> downloads);
/// <summary>
/// Rejects a torrent list that lost any row the client reported.
@@ -157,12 +98,12 @@ public abstract class DownloadService : IDownloadService
}
protected async Task<IReadOnlyList<string>> BuildClaimedPathsAsync(
IReadOnlyList<ITorrentItemWrapper> torrents,
Func<ITorrentItemWrapper, Task<IReadOnlyCollection<string>>> resolveRelativeFilePaths)
IReadOnlyList<IDownloadItem> downloads,
Func<IDownloadItem, Task<IReadOnlyCollection<string>>> resolveRelativeFilePaths)
{
HashSet<string> claimed = new(StringComparer.OrdinalIgnoreCase);
foreach (ITorrentItemWrapper torrent in torrents)
foreach (IDownloadItem torrent in downloads)
{
IReadOnlyCollection<string> relativeFilePaths;
try
@@ -185,9 +126,9 @@ public abstract class DownloadService : IDownloadService
}
/// <summary>
/// The top-level entries a torrent occupies.
/// The top-level entries a download occupies.
/// </summary>
private IReadOnlyList<string> BuildClaimedPaths(ITorrentItemWrapper torrent, IReadOnlyCollection<string> relativeFilePaths)
private IReadOnlyList<string> BuildClaimedPaths(IDownloadItem torrent, IReadOnlyCollection<string> relativeFilePaths)
{
List<string> claimed = [];
if (string.IsNullOrEmpty(torrent.SavePath))
@@ -226,198 +167,6 @@ public abstract class DownloadService : IDownloadService
.NormalizeAndRemap(path, _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget)
.TrimEnd(Path.DirectorySeparatorChar);
/// <inheritdoc/>
public virtual List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
/// <inheritdoc/>
public virtual List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> 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);
}
}
/// <inheritdoc/>
public async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? 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);
}
}
/// <summary>
/// Maps a torrent's files to (path, <see cref="HardLinkScanAction"/>) pairs.
/// Returns null to skip the torrent, such as when its files can't be read.
/// </summary>
protected abstract Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent);
/// <summary>
/// Stops at the first hardlink or unreadable file.
/// Each client maps its files to (path, <see cref="HardLinkScanAction"/>) pairs.
/// </summary>
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);
}
/// <summary>
/// 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
}
}
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Clients without tags fall back to a category change when asked for a tag.
/// </summary>
protected virtual bool SupportsTags => false;
/// <summary>
/// Applies the category, or the tag when <paramref name="useTag"/> is set, in the client.
/// </summary>
protected abstract Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
/// <inheritdoc/>
public abstract Task CreateCategoryAsync(string name);
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc cref="IFileBlockingCapable.BlockUnwantedFilesAsync"/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string downloadId, IReadOnlyList<string> ignoredDownloads);
/// <summary>
/// Deletes the specified download from the download client.
/// Each client implementation handles the deletion according to its API requirements.
/// </summary>
/// <param name="torrent">The torrent to delete</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent</param>
public abstract Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles);
/// <param name="item">The download item to delete</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the download</param>
public abstract Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles);
/// <summary>
/// Stops the specified download in the download client, leaving it there.
/// Each client implementation handles the stop according to its API requirements.
/// </summary>
/// <param name="torrent">The torrent to stop</param>
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<string>(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<string>(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<string>(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<string>(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)
@@ -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<TransmissionService>(_serviceProvider, downloadClientConfig),
DownloadClientTypeName.uTorrent => ActivatorUtilities.CreateInstance<UTorrentService>(_serviceProvider, downloadClientConfig),
DownloadClientTypeName.rTorrent => ActivatorUtilities.CreateInstance<RTorrentService>(_serviceProvider, downloadClientConfig),
DownloadClientTypeName.Sabnzbd => ActivatorUtilities.CreateInstance<SabnzbdService>(_serviceProvider, downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -0,0 +1,9 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// Torrent clients that report a seeder count, so dead-torrent detection applies.
/// rTorrent does not.
/// </summary>
public interface IDeadTorrentCapable : ISeedingCleanupCapable, IUnlinkedCapable
{
}
@@ -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;
/// <summary>
/// Base contract every download client implements, regardless of protocol or capabilities.
/// Protocol-specific behaviour lives behind the <c>I*Capable</c> interfaces.
/// </summary>
public interface IDownloadService : IDisposable
{
DownloadClientConfig ClientConfig { get; }
public Task LoginAsync();
Task LoginAsync();
/// <summary>
/// Performs a health check on the download client
/// </summary>
/// <returns>The health check result</returns>
public Task<HealthCheckResult> HealthCheckAsync();
/// <summary>
/// Checks whether the download should be removed from the *arr queue.
/// </summary>
/// <param name="hash">The download hash.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
public Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <summary>
/// Fetches all seeding downloads.
/// </summary>
/// <returns>A list of downloads that are seeding.</returns>
Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <summary>
/// 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.
/// </summary>
/// <returns>A list of all torrents.</returns>
Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
/// <summary>
/// Resolves the on-disk paths claimed by the given torrents.
/// </summary>
/// <returns>The distinct, remapped paths claimed by the torrents.</returns>
Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
/// <summary>
/// Filters downloads that should be cleaned.
/// </summary>
/// <param name="downloads">The downloads to filter.</param>
/// <param name="seedingRules">The seeding rules by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <summary>
/// Filters downloads that should have their category changed.
/// </summary>
/// <param name="downloads">The downloads to filter.</param>
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Cleans the downloads.
/// </summary>
/// <param name="downloads">The downloads to clean.</param>
/// <param name="seedingRules">The seeding rules.</param>
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <summary>
/// Changes the category for downloads that have no hardlinks.
/// </summary>
/// <param name="downloads">The downloads to change.</param>
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Moves a single torrent to the target category, or adds it as a tag/label when <paramref name="useTag"/> is set.
/// </summary>
/// <param name="torrent">The torrent to move.</param>
/// <param name="targetCategory">The target category/tag.</param>
/// <param name="useTag">When true, add a tag/label instead of changing the category (qBittorrent and Transmission).</param>
Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
Task<HealthCheckResult> HealthCheckAsync();
/// <summary>
/// Deletes a download item.
/// </summary>
/// <param name="torrent">The torrent item.</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent. Defaults to true.</param>
public Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles);
/// <summary>
/// Stops a download, leaving it in the client.
/// </summary>
/// <param name="torrent">The torrent item.</param>
public Task StopDownload(ITorrentItemWrapper torrent);
/// <summary>
/// Creates a category.
/// </summary>
/// <param name="name">The category name.</param>
public Task CreateCategoryAsync(string name);
/// <summary>
/// Blocks unwanted files from being fully downloaded.
/// </summary>
/// <param name="hash">The torrent hash.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
/// <returns>True if all files have been blocked; otherwise false.</returns>
public Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
}
/// <param name="item">The download item.</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the download. Defaults to true.</param>
Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles);
}
@@ -0,0 +1,16 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// Download clients that can block unwanted files from completing.
/// Implemented by every current client (torrent and usenet).
/// </summary>
public interface IFileBlockingCapable : IDownloadService
{
/// <summary>
/// Blocks unwanted files from being fully downloaded.
/// </summary>
/// <param name="downloadId">The download id.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
/// <returns>True if all files have been blocked; otherwise false.</returns>
Task<BlockFilesResult> BlockUnwantedFilesAsync(string downloadId, IReadOnlyList<string> ignoredDownloads);
}
@@ -0,0 +1,23 @@
using Cleanuparr.Domain.Entities;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// Download clients that can report the on-disk paths their downloads claim, for orphaned-files cleanup.
/// Implemented by every current client (torrent and usenet).
/// </summary>
public interface IOrphanClaimsCapable : IDownloadService
{
/// <summary>
/// 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.
/// </summary>
/// <returns>A list of all downloads.</returns>
Task<List<IDownloadItem>> GetAllDownloadsLite();
/// <summary>
/// Resolves the on-disk paths claimed by the given downloads.
/// </summary>
/// <returns>The distinct, remapped paths claimed by the downloads.</returns>
Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<IDownloadItem> downloads);
}
@@ -0,0 +1,15 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// Download clients that can report whether an *arr queue item should be removed.
/// Implemented by every current client (torrent and usenet).
/// </summary>
public interface IQueueCheckCapable : IDownloadService
{
/// <summary>
/// Checks whether the download should be removed from the *arr queue.
/// </summary>
/// <param name="downloadId">The download id.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList<string> ignoredDownloads);
}
@@ -0,0 +1,38 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// 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.
/// </summary>
public interface ISeedingCleanupCapable : IDownloadService
{
/// <summary>
/// Fetches all seeding downloads.
/// </summary>
/// <returns>A list of downloads that are seeding.</returns>
Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <summary>
/// Filters downloads that should be cleaned.
/// </summary>
/// <param name="downloads">The downloads to filter.</param>
/// <param name="seedingRules">The seeding rules by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <summary>
/// Cleans the downloads.
/// </summary>
/// <param name="downloads">The downloads to clean.</param>
/// <param name="seedingRules">The seeding rules.</param>
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
/// <summary>
/// Stops a download, leaving it in the client.
/// </summary>
/// <param name="item">The download item.</param>
Task StopDownload(IDownloadItem item);
}
@@ -0,0 +1,40 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// 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.
/// </summary>
public interface IUnlinkedCapable : IDownloadService
{
/// <summary>
/// Filters downloads that should have their category changed.
/// </summary>
/// <param name="downloads">The downloads to filter.</param>
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Changes the category for downloads that have no hardlinks.
/// </summary>
/// <param name="downloads">The downloads to change.</param>
/// <param name="unlinkedConfig">The unlinked config for this download client.</param>
Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
/// <summary>
/// Moves a single torrent to the target category, or adds it as a tag/label when <paramref name="useTag"/> is set.
/// </summary>
/// <param name="torrent">The torrent to move.</param>
/// <param name="targetCategory">The target category/tag.</param>
/// <param name="useTag">When true, add a tag/label instead of changing the category (qBittorrent and Transmission).</param>
Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
/// <summary>
/// Creates a category.
/// </summary>
/// <param name="name">The category name.</param>
Task CreateCategoryAsync(string name);
}
@@ -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;
}
@@ -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);
}
}
@@ -10,23 +10,23 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
public partial class QBitService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string downloadId, IReadOnlyList<string> 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<TorrentTracker>? trackers = await GetTrackersAsync(hash);
IReadOnlyList<TorrentTracker>? 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<ContentBlockerConfig>();
@@ -63,7 +63,7 @@ public partial class QBitService
return result;
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
IReadOnlyList<TorrentContent>? 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);
}
});
@@ -41,7 +41,7 @@ public partial class QBitService
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
public override async Task<List<IDownloadItem>> GetAllDownloadsLite()
{
IReadOnlyList<TorrentInfo>? 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<ITorrentItemWrapper> torrents = torrentList
List<IDownloadItem> 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
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
BuildClaimedPathsAsync(torrents, async torrent =>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<IDownloadItem> downloads) =>
BuildClaimedPathsAsync(downloads, async torrent =>
{
if (string.IsNullOrEmpty(torrent.Hash))
if (string.IsNullOrEmpty(torrent.DownloadId))
{
return [];
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.Hash);
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.DownloadId);
return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? [];
});
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> 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<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? 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
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
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<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent)
{
QBitItemWrapper qBitTorrent = (QBitItemWrapper)torrent;
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(qBitTorrent.Hash);
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(qBitTorrent.DownloadId);
if (files is null)
{
@@ -166,7 +166,7 @@ public partial class QBitService
/// <inheritdoc/>
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)
{
@@ -11,27 +11,27 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
public partial class QBitService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList<string> 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<TorrentTracker>? trackers = await GetTrackersAsync(hash);
IReadOnlyList<TorrentTracker>? 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<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
IReadOnlyList<TorrentContent>? 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
@@ -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;
}
@@ -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()
{
}
}
@@ -9,17 +9,17 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string downloadId, IReadOnlyList<string> 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);
}
});
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more