mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Add SABnzbd support (#825)
Co-authored-by: Flaminel <flm.marius@gmail.com>
This commit is contained in:
1 parent
a896e0c400
commit
de16a41b6c
210 files changed
+12742
-1377
No files matched your search
@@ -67,6 +67,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
- **SABnzbd**
|
||||
|
||||
### Platforms
|
||||
- **Docker**
|
||||
|
||||
+10
@@ -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()
|
||||
{
|
||||
|
||||
+35
@@ -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()
|
||||
{
|
||||
|
||||
+11
@@ -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()
|
||||
{
|
||||
|
||||
+22
-2
@@ -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);
|
||||
|
||||
+257
-8
@@ -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"),
|
||||
};
|
||||
}
|
||||
+41
-2
@@ -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",
|
||||
};
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+5
@@ -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))
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
|
||||
+19
-3
@@ -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,
|
||||
|
||||
+21
-4
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+14
-3
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+27
@@ -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(),
|
||||
};
|
||||
}
|
||||
+15
-3
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+13
-13
@@ -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>>())
|
||||
|
||||
+206
@@ -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<T>()</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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
{
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+14
-14
@@ -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");
|
||||
|
||||
|
||||
+2
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
+85
@@ -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)
|
||||
|
||||
+136
@@ -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'");
|
||||
}
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
+1130
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -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();
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+8
-8
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+10
-10
@@ -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>>())
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+120
-22
@@ -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();
|
||||
|
||||
+68
-21
@@ -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");
|
||||
|
||||
+18
-21
@@ -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);
|
||||
|
||||
+2
-2
@@ -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()
|
||||
{
|
||||
|
||||
+13
@@ -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
|
||||
{
|
||||
}
|
||||
+7
-4
@@ -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")
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+41
-16
@@ -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
|
||||
|
||||
+40
-1
@@ -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);
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
+37
-11
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
|
||||
+1
-1
@@ -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; }
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+2
-6
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -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;
|
||||
|
||||
+13
-13
@@ -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
-9
@@ -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)
|
||||
{
|
||||
|
||||
+2
-2
@@ -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; }
|
||||
}
|
||||
+28
-455
@@ -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)
|
||||
|
||||
+2
@@ -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
|
||||
{
|
||||
}
|
||||
+10
-91
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+8
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+15
-15
@@ -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)
|
||||
{
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+1
-5
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user