mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 11:59:02 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd55356881 | ||
|
|
df986a2e36 | ||
|
|
8a2aca79f7 | ||
|
|
e1cc0dba28 | ||
|
|
660dce0fa7 | ||
|
|
ee343ba469 | ||
|
|
d038738008 | ||
|
|
70f9995041 | ||
|
|
6a1aaec7c2 | ||
|
|
50e486bf5f | ||
|
|
bc15a9a934 | ||
|
|
573dbcf882 |
No files matched your search
@@ -101,14 +101,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
|
||||
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
|
||||
|
||||
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle"></span></span>
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
|
||||
@@ -15,6 +15,12 @@ ifndef name
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-users name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
|
||||
/// The database file is created in a temp directory so both DI and static contexts share the same data.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove the existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Also remove the DbContext registration itself
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Ensure DB is created
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the authentication flow.
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "another",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||
|
||||
// Store the secret for the next test
|
||||
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||
{
|
||||
// If we don't have the secret from the previous test, generate it again
|
||||
if (string.IsNullOrEmpty(_totpSecret))
|
||||
{
|
||||
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
var code = GenerateTotpCode(_totpSecret);
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Setup_Complete_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||
{
|
||||
// Make multiple failed attempts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
|
||||
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
public sealed class PriorityOrderer : ITestCaseOrderer
|
||||
{
|
||||
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
|
||||
where TTestCase : ITestCase
|
||||
{
|
||||
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var priority = testCase.TestMethod.Method
|
||||
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
|
||||
.FirstOrDefault()
|
||||
?.GetNamedArgument<int>("Priority") ?? 0;
|
||||
|
||||
if (!sortedMethods.TryGetValue(priority, out var list))
|
||||
{
|
||||
list = [];
|
||||
sortedMethods[priority] = list;
|
||||
}
|
||||
|
||||
list.Add(testCase);
|
||||
}
|
||||
|
||||
foreach (var list in sortedMethods.Values)
|
||||
{
|
||||
foreach (var testCase in list)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class TestPriorityAttribute : Attribute
|
||||
{
|
||||
public int Priority { get; }
|
||||
|
||||
public TestPriorityAttribute(int priority)
|
||||
{
|
||||
Priority = priority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.Auth;
|
||||
|
||||
public static class ApiKeyAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "ApiKey";
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
public const string QueryParameterName = "apikey";
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Try header first, then query string
|
||||
string? apiKey = null;
|
||||
|
||||
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
|
||||
{
|
||||
apiKey = headerValue.ToString();
|
||||
}
|
||||
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
|
||||
{
|
||||
apiKey = queryValue.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("auth_method", "apikey")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.Auth;
|
||||
|
||||
public static class TrustedNetworkAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "TrustedNetwork";
|
||||
}
|
||||
|
||||
public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TrustedNetworkAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Load auth config from database
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (config is null || !config.Auth.DisableAuthForLocalAddresses)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Determine client IP
|
||||
var clientIp = GetClientIp(config.Auth.TrustForwardedHeaders);
|
||||
if (clientIp is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Check if the client IP is trusted
|
||||
if (!IsTrustedAddress(clientIp, config.Auth.TrustedNetworks))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Load the admin user
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.SetupCompleted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("auth_method", "trusted_network")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, TrustedNetworkAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, TrustedNetworkAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
private IPAddress? GetClientIp(bool trustForwardedHeaders) =>
|
||||
ResolveClientIp(Context, trustForwardedHeaders);
|
||||
|
||||
public static IPAddress? ResolveClientIp(HttpContext httpContext, bool trustForwardedHeaders)
|
||||
{
|
||||
var remoteIp = httpContext.Connection.RemoteIpAddress;
|
||||
if (remoteIp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only trust forwarded headers if the direct connection is from a local address
|
||||
if (trustForwardedHeaders && remoteIp.IsLocalAddress())
|
||||
{
|
||||
// Check X-Forwarded-For first, then X-Real-IP
|
||||
var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedFor))
|
||||
{
|
||||
// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
|
||||
// The first one is the original client
|
||||
var firstIp = forwardedFor.Split(',')[0].Trim();
|
||||
if (IPAddress.TryParse(firstIp, out var parsedIp))
|
||||
{
|
||||
return parsedIp;
|
||||
}
|
||||
}
|
||||
|
||||
var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out var realParsedIp))
|
||||
{
|
||||
return realParsedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return remoteIp;
|
||||
}
|
||||
|
||||
public static bool IsTrustedAddress(IPAddress clientIp, List<string> trustedNetworks)
|
||||
{
|
||||
// Normalize IPv4-mapped IPv6 addresses
|
||||
if (clientIp.IsIPv4MappedToIPv6)
|
||||
{
|
||||
clientIp = clientIp.MapToIPv4();
|
||||
}
|
||||
|
||||
// Check if it's a local address (built-in ranges)
|
||||
if (clientIp.IsLocalAddress())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against custom trusted networks
|
||||
foreach (var network in trustedNetworks)
|
||||
{
|
||||
if (MatchesCidr(clientIp, network))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool MatchesCidr(IPAddress address, string cidr)
|
||||
{
|
||||
if (cidr.Contains('/'))
|
||||
{
|
||||
var parts = cidr.Split('/');
|
||||
if (!IPAddress.TryParse(parts[0], out var networkAddress) ||
|
||||
!int.TryParse(parts[1], out var prefixLength))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize both addresses
|
||||
if (networkAddress.IsIPv4MappedToIPv6)
|
||||
networkAddress = networkAddress.MapToIPv4();
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
|
||||
// Must be same address family
|
||||
if (address.AddressFamily != networkAddress.AddressFamily)
|
||||
return false;
|
||||
|
||||
var addressBytes = address.GetAddressBytes();
|
||||
var networkBytes = networkAddress.GetAddressBytes();
|
||||
|
||||
// Compare bytes up to prefix length
|
||||
var fullBytes = prefixLength / 8;
|
||||
var remainingBits = prefixLength % 8;
|
||||
|
||||
for (var i = 0; i < fullBytes && i < addressBytes.Length; i++)
|
||||
{
|
||||
if (addressBytes[i] != networkBytes[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remainingBits > 0 && fullBytes < addressBytes.Length)
|
||||
{
|
||||
var mask = (byte)(0xFF << (8 - remainingBits));
|
||||
if ((addressBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plain IP match
|
||||
if (!IPAddress.TryParse(cidr, out var singleIp))
|
||||
return false;
|
||||
|
||||
if (singleIp.IsIPv4MappedToIPv6)
|
||||
singleIp = singleIp.MapToIPv4();
|
||||
|
||||
return address.Equals(singleIp);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -149,33 +149,6 @@ public class EventsController : ControllerBase
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets event statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<object>> GetEventStats()
|
||||
{
|
||||
var stats = new
|
||||
{
|
||||
TotalEvents = await _context.Events.CountAsync(),
|
||||
EventsBySeverity = await _context.Events
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync(),
|
||||
EventsByType = await _context.Events
|
||||
.GroupBy(e => e.EventType)
|
||||
.Select(g => new { EventType = g.Key.ToString(), Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count)
|
||||
.Take(10)
|
||||
.ToListAsync(),
|
||||
RecentEventsCount = await _context.Events
|
||||
.Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24))
|
||||
.CountAsync()
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers cleanup of old events
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics endpoint for dashboard integrations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StatsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatsController> _logger;
|
||||
private readonly IStatsService _statsService;
|
||||
|
||||
public StatsController(
|
||||
ILogger<StatsController> logger,
|
||||
IStatsService statsService)
|
||||
{
|
||||
_logger = logger;
|
||||
_statsService = statsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated application statistics for the specified timeframe
|
||||
/// </summary>
|
||||
/// <param name="hours">Timeframe in hours (default 24, range 1-720)</param>
|
||||
/// <param name="includeEvents">Number of recent events to include (0 = none, max 100)</param>
|
||||
/// <param name="includeStrikes">Number of recent strikes to include (0 = none, max 100)</param>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetStats(
|
||||
[FromQuery] int hours = 24,
|
||||
[FromQuery] int includeEvents = 0,
|
||||
[FromQuery] int includeStrikes = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
hours = Math.Clamp(hours, 1, 720);
|
||||
includeEvents = Math.Clamp(includeEvents, 0, 100);
|
||||
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
|
||||
|
||||
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
|
||||
return Ok(stats);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving stats");
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving stats" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,13 @@ public static class ApiDI
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
// Block non-auth requests until setup is complete
|
||||
app.UseMiddleware<SetupGuardMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -108,11 +112,11 @@ public static class ApiDI
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
|
||||
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
@@ -124,12 +128,18 @@ public static class ApiDI
|
||||
{
|
||||
name = "Cleanuparr",
|
||||
short_name = "Cleanuparr",
|
||||
description = "Automated cleanup for *arr applications and download clients",
|
||||
start_url = basePath,
|
||||
display = "standalone",
|
||||
background_color = "#ffffff",
|
||||
theme_color = "#ffffff",
|
||||
background_color = "#0e0a1a",
|
||||
theme_color = "#1a1135",
|
||||
icons = new[]
|
||||
{
|
||||
new {
|
||||
src = "icons/128.png",
|
||||
sizes = "128x128",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
@@ -144,7 +154,7 @@ public static class ApiDI
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class AuthDI
|
||||
{
|
||||
private const string SmartScheme = "Smart";
|
||||
|
||||
public static IServiceCollection AddAuthServices(this IServiceCollection services)
|
||||
{
|
||||
// Get the signing key from the JwtService
|
||||
var jwtService = new JwtService();
|
||||
var signingKey = jwtService.GetOrCreateSigningKey();
|
||||
|
||||
services
|
||||
.AddAuthentication(SmartScheme)
|
||||
.AddPolicyScheme(SmartScheme, "JWT or API Key", options =>
|
||||
{
|
||||
// Route to the correct auth handler based on the request
|
||||
options.ForwardDefaultSelector = context =>
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName) ||
|
||||
context.Request.Query.ContainsKey(ApiKeyAuthenticationDefaults.QueryParameterName))
|
||||
{
|
||||
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
|
||||
}
|
||||
|
||||
// Check for Bearer token or SignalR access_token
|
||||
if (context.Request.Headers.ContainsKey("Authorization") ||
|
||||
(context.Request.Path.StartsWithSegments("/api/hubs") &&
|
||||
context.Request.Query.ContainsKey("access_token")))
|
||||
{
|
||||
return JwtBearerDefaults.AuthenticationScheme;
|
||||
}
|
||||
|
||||
// Fall through to trusted network handler (returns NoResult if disabled)
|
||||
return TrustedNetworkAuthenticationDefaults.AuthenticationScheme;
|
||||
};
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "Cleanuparr",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "Cleanuparr",
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(signingKey),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Support SignalR token via query string
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/api/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, TrustedNetworkAuthenticationHandler>(
|
||||
TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
options.DefaultPolicy = defaultPolicy;
|
||||
options.FallbackPolicy = defaultPolicy;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,8 @@ public static class MainDI
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 2;
|
||||
e.PrefetchCount = 2;
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
@@ -87,10 +87,13 @@ public static class MainDI
|
||||
{
|
||||
// Add the dynamic HTTP client system - this replaces all the previous static configurations
|
||||
services.AddDynamicHttpClients();
|
||||
|
||||
|
||||
// Add the dynamic HTTP client provider that uses the new system
|
||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||
|
||||
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
@@ -16,6 +17,7 @@ using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Cleanuparr.Persistence;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -26,6 +28,11 @@ public static class ServicesDI
|
||||
services
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<UsersContext>()
|
||||
.AddSingleton<IJwtService, JwtService>()
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
@@ -54,6 +61,7 @@ public static class ServicesDI
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddScoped<IStatsService, StatsService>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record ChangePasswordRequest
|
||||
{
|
||||
[Required]
|
||||
public required string CurrentPassword { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string NewPassword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record CreateAccountRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
[MaxLength(50)]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Disable2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Enable2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record PlexPinRequest
|
||||
{
|
||||
[Required]
|
||||
public required int PinId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record RefreshTokenRequest
|
||||
{
|
||||
[Required]
|
||||
public required string RefreshToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Regenerate2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record TwoFactorRequest
|
||||
{
|
||||
[Required]
|
||||
public required string LoginToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
|
||||
public bool IsRecoveryCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record VerifyTotpRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AccountInfoResponse
|
||||
{
|
||||
public required string Username { get; init; }
|
||||
public required bool PlexLinked { get; init; }
|
||||
public string? PlexUsername { get; init; }
|
||||
public required bool TwoFactorEnabled { get; init; }
|
||||
public required string ApiKeyPreview { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AuthStatusResponse
|
||||
{
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
public bool AuthBypassActive { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
public required bool RequiresTwoFactor { get; init; }
|
||||
public string? LoginToken { get; init; }
|
||||
public TokenResponse? Tokens { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record PlexPinStatusResponse
|
||||
{
|
||||
public required int PinId { get; init; }
|
||||
public required string AuthUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexVerifyResponse
|
||||
{
|
||||
public required bool Completed { get; init; }
|
||||
public TokenResponse? Tokens { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record TokenResponse
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record TotpSetupResponse
|
||||
{
|
||||
public required string Secret { get; init; }
|
||||
public required string QrCodeUri { get; init; }
|
||||
public required List<string> RecoveryCodes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/account")]
|
||||
[Authorize]
|
||||
public sealed class AccountController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
UsersContext usersContext,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAccountInfo()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new AccountInfoResponse
|
||||
{
|
||||
Username = user.Username,
|
||||
PlexLinked = user.PlexAccountId is not null,
|
||||
PlexUsername = user.PlexUsername,
|
||||
TwoFactorEnabled = user.TotpEnabled,
|
||||
ApiKeyPreview = user.ApiKey[..8] + "..."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable/verify")]
|
||||
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("api-key")]
|
||||
public async Task<IActionResult> GetApiKey()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
|
||||
[HttpPost("api-key/regenerate")]
|
||||
public async Task<IActionResult> RegenerateApiKey()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("plex/link")]
|
||||
public async Task<IActionResult> StartPlexLink()
|
||||
{
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||
}
|
||||
|
||||
[HttpPost("plex/link/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new { completed = false });
|
||||
}
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = _usersContext.Users.AsQueryable();
|
||||
|
||||
if (includeRecoveryCodes)
|
||||
{
|
||||
query = query.Include(u => u.RecoveryCodes);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[AllowAnonymous]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
IJwtService jwtService,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
var authBypass = false;
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
|
||||
{
|
||||
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
|
||||
HttpContext, generalConfig.Auth.TrustForwardedHeaders);
|
||||
if (clientIp is not null)
|
||||
{
|
||||
authBypass = TrustedNetworkAuthenticationHandler.IsTrustedAddress(
|
||||
clientIp, generalConfig.Auth.TrustedNetworks);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new AuthStatusResponse
|
||||
{
|
||||
SetupCompleted = user is { SetupCompleted: true },
|
||||
PlexLinked = user?.PlexAccountId is not null,
|
||||
AuthBypassActive = authBypass
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup/account")]
|
||||
public async Task<IActionResult> CreateAccount([FromBody] CreateAccountRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Conflict(new { error = "Account already exists" });
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
PasswordHash = _passwordService.HashPassword(request.Password),
|
||||
TotpSecret = string.Empty,
|
||||
TotpEnabled = false,
|
||||
ApiKey = GenerateApiKey(),
|
||||
SetupCompleted = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_usersContext.Users.Add(user);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username);
|
||||
|
||||
return Created("", new { userId = user.Id });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/2fa/generate")]
|
||||
public async Task<IActionResult> GenerateTotpSetup()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted && user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already configured" });
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
|
||||
// Generate recovery codes
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
// Store secret (will be finalized on verify)
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove old recovery codes and add new ones
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/2fa/verify")]
|
||||
public async Task<IActionResult> VerifyTotpSetup([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA verified and enabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/complete")]
|
||||
public async Task<IActionResult> CompleteSetup()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
user.SetupCompleted = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Setup completed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Setup complete" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid credentials" });
|
||||
}
|
||||
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
|
||||
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
|
||||
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful password verification
|
||||
await ResetFailedAttempts(user.Id);
|
||||
|
||||
// If 2FA is not enabled, issue tokens directly
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
// Re-fetch with tracking since the query above used AsNoTracking
|
||||
var trackedUser = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
|
||||
var tokenResponse = await GenerateTokenResponse(trackedUser);
|
||||
|
||||
_logger.LogInformation("User {Username} logged in (2FA disabled)", user.Username);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = false,
|
||||
Tokens = tokenResponse
|
||||
});
|
||||
}
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(user.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||
{
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired login token" });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid login token" });
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||
|
||||
var storedToken = await _usersContext.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired refresh token" });
|
||||
}
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
|
||||
// Generate new tokens
|
||||
var response = await GenerateTokenResponse(storedToken.User);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||
|
||||
var storedToken = await _usersContext.RefreshTokens
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is not null)
|
||||
{
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { message = "Logged out" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("setup/plex/pin")]
|
||||
public async Task<IActionResult> RequestSetupPlexPin()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
PinId = pin.PinId,
|
||||
AuthUrl = pin.AuthUrl
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup/plex/verify")]
|
||||
public async Task<IActionResult> VerifySetupPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new PlexVerifyResponse { Completed = false });
|
||||
}
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new PlexVerifyResponse { Completed = true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login/plex/pin")]
|
||||
public async Task<IActionResult> RequestPlexPin()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
PinId = pin.PinId,
|
||||
AuthUrl = pin.AuthUrl
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login/plex/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||
{
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
{
|
||||
return Ok(new PlexVerifyResponse { Completed = false });
|
||||
}
|
||||
|
||||
// Verify the Plex account matches the linked one
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
if (plexAccount.AccountId != user.PlexAccountId)
|
||||
{
|
||||
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||
}
|
||||
|
||||
// Plex login bypasses 2FA
|
||||
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
return Ok(new PlexVerifyResponse
|
||||
{
|
||||
Completed = true,
|
||||
Tokens = tokenResponse
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||
{
|
||||
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||
var refreshToken = _jwtService.GenerateRefreshToken();
|
||||
|
||||
_usersContext.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
TokenHash = HashRefreshToken(refreshToken),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = 60 // seconds
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> TryUseRecoveryCode(User user, string code)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
|
||||
{
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
|
||||
|
||||
return user.FailedLoginAttempts * 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string HashRefreshToken(string token)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(token);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
@@ -6,7 +7,12 @@ public record SeedingRuleRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Which torrent privacy types this rule applies to.
|
||||
/// </summary>
|
||||
public TorrentPrivacyType PrivacyType { get; init; } = TorrentPrivacyType.Public;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
|
||||
-2
@@ -13,8 +13,6 @@ public sealed record UpdateDownloadCleanerConfigRequest
|
||||
|
||||
public List<SeedingRuleRequest> Categories { get; init; } = [];
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether unlinked download handling is enabled.
|
||||
/// </summary>
|
||||
|
||||
+1
-1
@@ -76,7 +76,6 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
oldConfig.Enabled = newConfigDto.Enabled;
|
||||
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
|
||||
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
@@ -93,6 +92,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
_dataContext.SeedingRules.Add(new SeedingRule
|
||||
{
|
||||
Name = categoryDto.Name,
|
||||
PrivacyType = categoryDto.PrivacyType,
|
||||
MaxRatio = categoryDto.MaxRatio,
|
||||
MinSeedTime = categoryDto.MinSeedTime,
|
||||
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||
|
||||
+20
@@ -34,6 +34,8 @@ public sealed record UpdateGeneralConfigRequest
|
||||
|
||||
public UpdateLoggingConfigRequest Log { get; init; } = new();
|
||||
|
||||
public UpdateAuthConfigRequest Auth { get; init; } = new();
|
||||
|
||||
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
|
||||
{
|
||||
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
|
||||
@@ -49,6 +51,7 @@ public sealed record UpdateGeneralConfigRequest
|
||||
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
|
||||
|
||||
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
|
||||
Auth.ApplyTo(existingConfig.Auth);
|
||||
|
||||
Validate(existingConfig);
|
||||
|
||||
@@ -75,6 +78,7 @@ public sealed record UpdateGeneralConfigRequest
|
||||
}
|
||||
|
||||
config.Log.Validate();
|
||||
config.Auth.Validate();
|
||||
}
|
||||
|
||||
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
|
||||
@@ -145,3 +149,19 @@ public sealed record UpdateLoggingConfigRequest
|
||||
|
||||
public bool LevelOnlyChange { get; private set; }
|
||||
}
|
||||
|
||||
public sealed record UpdateAuthConfigRequest
|
||||
{
|
||||
public bool DisableAuthForLocalAddresses { get; init; }
|
||||
|
||||
public bool TrustForwardedHeaders { get; init; }
|
||||
|
||||
public List<string> TrustedNetworks { get; init; } = [];
|
||||
|
||||
public void ApplyTo(AuthConfig existingConfig)
|
||||
{
|
||||
existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses;
|
||||
existingConfig.TrustForwardedHeaders = TrustForwardedHeaders;
|
||||
existingConfig.TrustedNetworks = TrustedNetworks;
|
||||
}
|
||||
}
|
||||
+15
-44
@@ -3,12 +3,9 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Controllers;
|
||||
@@ -19,16 +16,13 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<GeneralConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly MemoryCache _cache;
|
||||
|
||||
public GeneralConfigController(
|
||||
ILogger<GeneralConfigController> logger,
|
||||
DataContext dataContext,
|
||||
MemoryCache cache)
|
||||
DataContext dataContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpGet("general")]
|
||||
@@ -49,7 +43,9 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("general")]
|
||||
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
|
||||
public async Task<IActionResult> UpdateGeneralConfig(
|
||||
[FromBody] UpdateGeneralConfigRequest request,
|
||||
[FromServices] EventsContext eventsContext)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
@@ -63,7 +59,17 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
|
||||
if (wasDryRun && !config.DryRun)
|
||||
{
|
||||
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
|
||||
var deletedItems = await eventsContext.DownloadItems
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dry run disabled — purged all strikes: {Strikes} strikes, {Items} download items removed",
|
||||
deletedStrikes, deletedItems);
|
||||
}
|
||||
|
||||
return Ok(new { Message = "General configuration updated successfully" });
|
||||
}
|
||||
@@ -92,39 +98,4 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
|
||||
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
|
||||
}
|
||||
|
||||
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
|
||||
{
|
||||
if (!wasDryRun || isDryRun)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<object> keys;
|
||||
|
||||
// Remove strikes
|
||||
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
|
||||
{
|
||||
keys = _cache.Keys
|
||||
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.ToList();
|
||||
|
||||
foreach (object key in keys)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
|
||||
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
|
||||
}
|
||||
|
||||
// Remove strike cache items
|
||||
keys = _cache.Keys
|
||||
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.ToList();
|
||||
|
||||
foreach (object key in keys)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,14 @@ public static class HostExtensions
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
|
||||
// Apply users db migrations
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await usersContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Middleware;
|
||||
|
||||
public class SetupGuardMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private volatile bool _setupCompleted;
|
||||
|
||||
public SetupGuardMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: setup already completed
|
||||
if (_setupCompleted)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow these paths regardless of setup state
|
||||
if (IsAllowedPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check database for setup completion
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is { SetupCompleted: true })
|
||||
{
|
||||
_setupCompleted = true;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup not complete - block non-auth requests
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached setup state. Call this if the user database is reset.
|
||||
/// </summary>
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private static bool IsAllowedPath(string path)
|
||||
{
|
||||
return path.StartsWith("/api/auth/")
|
||||
|| path == "/api/auth"
|
||||
|| path.StartsWith("/health")
|
||||
|| !path.StartsWith("/api/");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
@@ -70,12 +71,19 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
// Add services to the container
|
||||
builder.Services
|
||||
.AddInfrastructure(builder.Configuration)
|
||||
.AddApiServices();
|
||||
.AddApiServices()
|
||||
.AddAuthServices();
|
||||
|
||||
// Persist Data Protection keys to the config directory
|
||||
builder.Services
|
||||
.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
|
||||
.SetApplicationName("Cleanuparr");
|
||||
|
||||
// Add CORS before SignalR
|
||||
builder.Services.AddCors(options =>
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Any", policy =>
|
||||
options.AddPolicy("Any", policy =>
|
||||
{
|
||||
policy
|
||||
// https://github.com/dotnet/aspnetcore/issues/4457#issuecomment-465669576
|
||||
@@ -123,6 +131,13 @@ if (basePath is not null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(basePath) && !context.Request.Path.StartsWithSegments(basePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Redirect root to the base path for convenience
|
||||
if (!context.Request.Path.HasValue || context.Request.Path.Value == "/")
|
||||
{
|
||||
context.Response.Redirect(basePath + "/");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
@@ -146,14 +161,14 @@ app.Init();
|
||||
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
|
||||
SignalRLogSink.Instance.SetAppHubContext(appHub);
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
// Configure health check endpoints as middleware (before auth pipeline) so they don't require authentication
|
||||
app.UseHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("liveness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
app.UseHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("readiness"),
|
||||
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
|
||||
|
||||
@@ -27,6 +27,8 @@ public interface ITorrentItemWrapper
|
||||
long SeedingTimeSeconds { get; }
|
||||
|
||||
string? Category { get; set; }
|
||||
|
||||
string SavePath { get; }
|
||||
|
||||
bool IsDownloading();
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file within a torrent from rTorrent's XML-RPC f.multicall response
|
||||
/// </summary>
|
||||
public sealed record RTorrentFile
|
||||
{
|
||||
/// <summary>
|
||||
/// File index within the torrent (0-based)
|
||||
/// </summary>
|
||||
public int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path relative to the torrent base directory
|
||||
/// </summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download priority: 0 = skip/don't download, 1 = normal, 2 = high
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of completed chunks for this file
|
||||
/// </summary>
|
||||
public long CompletedChunks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of chunks for this file
|
||||
/// </summary>
|
||||
public long SizeChunks { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a torrent from rTorrent's XML-RPC multicall response
|
||||
/// </summary>
|
||||
public sealed record RTorrentTorrent
|
||||
{
|
||||
/// <summary>
|
||||
/// Torrent info hash (40-character hex string, uppercase)
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Torrent name
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the torrent is from a private tracker (0 or 1)
|
||||
/// </summary>
|
||||
public int IsPrivate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of the torrent in bytes
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bytes completed/downloaded
|
||||
/// </summary>
|
||||
public long CompletedBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current download rate in bytes per second
|
||||
/// </summary>
|
||||
public long DownRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload/download ratio multiplied by 1000 (e.g., 1500 = 1.5 ratio)
|
||||
/// </summary>
|
||||
public long Ratio { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Torrent state: 0 = stopped, 1 = started
|
||||
/// </summary>
|
||||
public int State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completion status: 0 = incomplete, 1 = complete
|
||||
/// </summary>
|
||||
public int Complete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unix timestamp when the torrent finished downloading (0 if not finished)
|
||||
/// </summary>
|
||||
public long TimestampFinished { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Label/category from d.custom1 (commonly used by ruTorrent for labels)
|
||||
/// </summary>
|
||||
public string? Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base path where the torrent data is stored
|
||||
/// </summary>
|
||||
public string? BasePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of tracker URLs for this torrent
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Trackers { get; init; }
|
||||
}
|
||||
@@ -6,4 +6,5 @@ public enum DownloadClientTypeName
|
||||
Deluge,
|
||||
Transmission,
|
||||
uTorrent,
|
||||
rTorrent,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public class RTorrentClientException : Exception
|
||||
{
|
||||
public RTorrentClientException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public RTorrentClientException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
@@ -340,13 +341,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -360,13 +363,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -380,13 +385,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, false);
|
||||
await sut.DeleteDownload(mockTorrent.Object, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
|
||||
+204
-2
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
@@ -302,6 +303,203 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
|
||||
}
|
||||
}
|
||||
|
||||
public class CleanDownloadsAsync_Tests : QBitServiceDCTests
|
||||
{
|
||||
public CleanDownloadsAsync_Tests(QBitServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
private static QBitItemWrapper CreateTorrent(string hash, string category, bool isPrivate) =>
|
||||
new(new TorrentInfo
|
||||
{
|
||||
Hash = hash,
|
||||
Name = $"Test {hash}",
|
||||
Category = category,
|
||||
Ratio = 2.0,
|
||||
SeedingTime = TimeSpan.FromHours(10)
|
||||
}, Array.Empty<TorrentTracker>(), isPrivate);
|
||||
|
||||
private static SeedingRule CreateRule(string name, TorrentPrivacyType privacyType) =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
PrivacyType = privacyType,
|
||||
MaxRatio = 0,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = -1,
|
||||
DeleteSourceFiles = false
|
||||
};
|
||||
|
||||
private void SetupDeleteMock()
|
||||
{
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsPrivateTorrent_WhenRuleIsPublicOnly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("hash1", "movies", isPrivate: true)
|
||||
};
|
||||
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Public) };
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleansPublicTorrent_WhenRuleIsPublicOnly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("hash1", "movies", isPrivate: false)
|
||||
};
|
||||
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Public) };
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsPublicTorrent_WhenRuleIsPrivateOnly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("hash1", "movies", isPrivate: false)
|
||||
};
|
||||
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Private) };
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleansPrivateTorrent_WhenRuleIsPrivateOnly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("hash1", "movies", isPrivate: true)
|
||||
};
|
||||
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Private) };
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleansPublicTorrent_WhenRuleIsBoth()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("hash1", "movies", isPrivate: false)
|
||||
};
|
||||
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Both) };
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleansPrivateTorrent_WhenRuleIsBoth()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("hash1", "movies", isPrivate: true)
|
||||
};
|
||||
var rules = new List<SeedingRule> { CreateRule("movies", TorrentPrivacyType.Both) };
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("hash1")), false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchesCorrectRule_WhenMultipleRulesForSameCategory()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
SetupDeleteMock();
|
||||
|
||||
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
|
||||
{
|
||||
CreateTorrent("public-hash", "movies", isPrivate: false),
|
||||
CreateTorrent("private-hash", "movies", isPrivate: true)
|
||||
};
|
||||
var rules = new List<SeedingRule>
|
||||
{
|
||||
CreateRule("movies", TorrentPrivacyType.Public),
|
||||
CreateRule("movies", TorrentPrivacyType.Private)
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.CleanDownloadsAsync(downloads, rules);
|
||||
|
||||
// Assert - both torrents should be cleaned, each matching their respective rule
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("public-hash")), false),
|
||||
Times.Once);
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains("private-hash")), false),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : QBitServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture)
|
||||
@@ -503,13 +701,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains(hash)), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -523,13 +723,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
|
||||
+582
@@ -0,0 +1,582 @@
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class RTorrentItemWrapperTests
|
||||
{
|
||||
public class PropertyMapping_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void MapsHash()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "ABC123DEF456", Name = "Test" };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ABC123DEF456", wrapper.Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsName()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test Torrent Name" };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Torrent Name", wrapper.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsIsPrivate_True()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", IsPrivate = 1 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.True(wrapper.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsIsPrivate_False()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", IsPrivate = 0 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsSize()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", SizeBytes = 1024000 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1024000, wrapper.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsDownloadSpeed()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", DownRate = 500000 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(500000, wrapper.DownloadSpeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsDownloadedBytes()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", CompletedBytes = 750000 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(750000, wrapper.DownloadedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsCategory()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("movies", wrapper.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CategoryIsSettable()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
wrapper.Category = "tv";
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tv", wrapper.Category);
|
||||
}
|
||||
}
|
||||
|
||||
public class Ratio_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertsRatioFromRTorrentFormat()
|
||||
{
|
||||
// rTorrent returns ratio * 1000, so 1500 = 1.5 ratio
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 1500 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1.5, wrapper.Ratio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesZeroRatio()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 0 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.Ratio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesHighRatio()
|
||||
{
|
||||
// Arrange - 10.0 ratio = 10000 in rTorrent
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 10000 };
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10.0, wrapper.Ratio);
|
||||
}
|
||||
}
|
||||
|
||||
public class CompletionPercentage_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void CalculatesCorrectPercentage()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(50.0, wrapper.CompletionPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsZero_WhenSizeIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
SizeBytes = 0,
|
||||
CompletedBytes = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.0, wrapper.CompletionPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsHundred_WhenComplete()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 1000
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100.0, wrapper.CompletionPercentage);
|
||||
}
|
||||
}
|
||||
|
||||
public class IsDownloading_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ReturnsTrue_WhenStateIsStartedAndNotComplete()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
State = 1, // Started
|
||||
Complete = 0 // Not complete
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.True(wrapper.IsDownloading());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsFalse_WhenStopped()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
State = 0, // Stopped
|
||||
Complete = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsDownloading());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsFalse_WhenComplete()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
State = 1, // Started
|
||||
Complete = 1 // Complete (seeding)
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsDownloading());
|
||||
}
|
||||
}
|
||||
|
||||
public class IsStalled_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ReturnsTrue_WhenDownloadingWithNoSpeed()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 0,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.True(wrapper.IsStalled());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsFalse_WhenDownloadingWithSpeed()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 100000,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsStalled());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsFalse_WhenNotDownloading()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
State = 0, // Stopped
|
||||
Complete = 0,
|
||||
DownRate = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.False(wrapper.IsStalled());
|
||||
}
|
||||
}
|
||||
|
||||
public class SeedingTime_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ReturnsZero_WhenNotComplete()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
Complete = 0,
|
||||
TimestampFinished = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.SeedingTimeSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsZero_WhenNoFinishTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
Complete = 1,
|
||||
TimestampFinished = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.SeedingTimeSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatesSeedingTime_WhenComplete()
|
||||
{
|
||||
// Arrange
|
||||
var finishedTime = DateTimeOffset.UtcNow.AddHours(-2).ToUnixTimeSeconds();
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
Complete = 1,
|
||||
TimestampFinished = finishedTime
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert - should be approximately 2 hours (7200 seconds)
|
||||
Assert.True(wrapper.SeedingTimeSeconds >= 7190 && wrapper.SeedingTimeSeconds <= 7210);
|
||||
}
|
||||
}
|
||||
|
||||
public class Eta_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ReturnsZero_WhenNoDownloadSpeed()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500,
|
||||
DownRate = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.Eta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatesEta_WhenDownloading()
|
||||
{
|
||||
// Arrange - 500 bytes remaining at 100 bytes/sec = 5 seconds ETA
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500,
|
||||
DownRate = 100
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, wrapper.Eta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsZero_WhenComplete()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 1000,
|
||||
DownRate = 100
|
||||
};
|
||||
|
||||
// Act
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, wrapper.Eta);
|
||||
}
|
||||
}
|
||||
|
||||
public class IsIgnored_Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ReturnsFalse_WhenEmptyIgnoreList()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(new List<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsTrue_WhenHashMatches()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "ABC123", Name = "Test", Label = "movies" };
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(new List<string> { "ABC123" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsTrue_WhenHashMatchesCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "ABC123", Name = "Test", Label = "movies" };
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(new List<string> { "abc123" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsTrue_WhenCategoryMatches()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(new List<string> { "movies" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsTrue_WhenTrackerDomainMatches()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
Label = "movies",
|
||||
Trackers = new List<string> { "https://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(new List<string> { "example.com" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsFalse_WhenNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
var torrent = new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Test",
|
||||
Label = "movies",
|
||||
Trackers = new List<string> { "https://tracker.example.com/announce" }
|
||||
};
|
||||
var wrapper = new RTorrentItemWrapper(torrent);
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(new List<string> { "other.com", "tv", "HASH2" });
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
{
|
||||
private readonly RTorrentServiceFixture _fixture;
|
||||
|
||||
public RTorrentServiceDCTests(RTorrentServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class GetSeedingDownloads_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public GetSeedingDownloads_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiltersSeedingState()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<RTorrentTorrent>
|
||||
{
|
||||
new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
|
||||
new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", State = 1, Complete = 0, IsPrivate = 0, Label = "" }, // Downloading, not seeding
|
||||
new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
|
||||
new RTorrentTorrent { Hash = "HASH4", Name = "Torrent 4", State = 0, Complete = 1, IsPrivate = 0, Label = "" } // Stopped, not seeding
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetAllTorrentsAsync())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert - only torrents with State=1 AND Complete=1 should be returned
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, item => Assert.NotNull(item.Hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNoTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetAllTorrentsAsync())
|
||||
.ReturnsAsync(new List<RTorrentTorrent>());
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsTorrentsWithEmptyHash()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<RTorrentTorrent>
|
||||
{
|
||||
new RTorrentTorrent { Hash = "", Name = "No Hash", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
|
||||
new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", State = 1, Complete = 1, IsPrivate = 0, Label = "" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetAllTorrentsAsync())
|
||||
.ReturnsAsync(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("HASH1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToBeCleanedAsync_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToBeCleanedAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "movies" }),
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", Label = "tv" }),
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, x => x.Category == "movies");
|
||||
Assert.Contains(result, x => x.Category == "tv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "Movies" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsNull_WhenDownloadsNull()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var categories = new List<SeedingRule>
|
||||
{
|
||||
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToBeCleanedAsync(null, categories);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class FilterDownloadsToChangeCategoryAsync_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public FilterDownloadsToChangeCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "movies" }),
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", Label = "tv" }),
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
|
||||
};
|
||||
|
||||
var categories = new List<string> { "movies", "tv" };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsEmptyHashes()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "", Name = "No Hash", Label = "movies" }),
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", Label = "movies" })
|
||||
};
|
||||
|
||||
var categories = new List<string> { "movies" };
|
||||
|
||||
// Act
|
||||
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("HASH1", result[0].Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteDownload_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public DeleteDownload_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizesHashToUppercase()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
var hash = "lowercase";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
mockTorrent.Setup(x => x.SavePath).Returns("/test/path");
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.DeleteTorrentAsync("LOWERCASE"))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(mockTorrent.Object, deleteSourceFiles: false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.DeleteTorrentAsync("LOWERCASE"),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryAsync_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public CreateCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsNoOp_BecauseRTorrentDoesNotSupportCategories()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.CreateCategoryAsync("test-category");
|
||||
|
||||
// Assert - no client calls should be made
|
||||
_fixture.ClientWrapper.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeCategoryForNoHardLinksAsync_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public ChangeCategoryForNoHardLinksAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(null);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloads_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHash_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingName_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingCategory_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFilesThrows_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ThrowsAsync(new Exception("XML-RPC error"));
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkippedFiles_IgnoredInCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 }, // Skipped
|
||||
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // Active
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - only called for file2.mkv (the active file)
|
||||
_fixture.HardLinkFileService.Verify(
|
||||
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoHardlinks_ChangesLabel()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync("HASH1", "unlinked"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasHardlinks_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(2); // Has hardlinks
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileNotFound_SkipsTorrent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(-1); // Error / file not found
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishesCategoryChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var downloads = new List<ITorrentItemWrapper>
|
||||
{
|
||||
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
_fixture.EventPublisher.Verify(
|
||||
x => x.PublishCategoryChanged("movies", "unlinked", false),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatesCategoryOnWrapper()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UnlinkedTargetCategory = "unlinked"
|
||||
};
|
||||
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
|
||||
|
||||
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
|
||||
var downloads = new List<ITorrentItemWrapper> { wrapper };
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.HardLinkFileService
|
||||
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("unlinked", wrapper.Category);
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
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 Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class RTorrentServiceFixture : IDisposable
|
||||
{
|
||||
public Mock<ILogger<RTorrentService>> Logger { get; }
|
||||
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
|
||||
public Mock<IStriker> Striker { get; }
|
||||
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
|
||||
public Mock<IHardLinkFileService> HardLinkFileService { get; }
|
||||
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
|
||||
public Mock<IEventPublisher> EventPublisher { get; }
|
||||
public Mock<IBlocklistProvider> BlocklistProvider { get; }
|
||||
public Mock<IRuleEvaluator> RuleEvaluator { get; }
|
||||
public Mock<IRuleManager> RuleManager { get; }
|
||||
public Mock<IRTorrentClientWrapper> ClientWrapper { get; }
|
||||
|
||||
public RTorrentServiceFixture()
|
||||
{
|
||||
Logger = new Mock<ILogger<RTorrentService>>();
|
||||
FilenameEvaluator = new Mock<IFilenameEvaluator>();
|
||||
Striker = new Mock<IStriker>();
|
||||
DryRunInterceptor = new Mock<IDryRunInterceptor>();
|
||||
HardLinkFileService = new Mock<IHardLinkFileService>();
|
||||
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
|
||||
EventPublisher = new Mock<IEventPublisher>();
|
||||
BlocklistProvider = new Mock<IBlocklistProvider>();
|
||||
RuleEvaluator = new Mock<IRuleEvaluator>();
|
||||
RuleManager = new Mock<IRuleManager>();
|
||||
ClientWrapper = new Mock<IRTorrentClientWrapper>();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public RTorrentService CreateSut(DownloadClientConfig? config = null)
|
||||
{
|
||||
config ??= new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test rTorrent Client",
|
||||
TypeName = Domain.Enums.DownloadClientTypeName.rTorrent,
|
||||
Type = Domain.Enums.DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost/RPC2"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
UrlBase = ""
|
||||
};
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
HttpClientProvider
|
||||
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(httpClient);
|
||||
|
||||
return new RTorrentService(
|
||||
Logger.Object,
|
||||
FilenameEvaluator.Object,
|
||||
Striker.Object,
|
||||
DryRunInterceptor.Object,
|
||||
HardLinkFileService.Object,
|
||||
HttpClientProvider.Object,
|
||||
EventPublisher.Object,
|
||||
BlocklistProvider.Object,
|
||||
config,
|
||||
RuleEvaluator.Object,
|
||||
RuleManager.Object,
|
||||
ClientWrapper.Object
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetMocks()
|
||||
{
|
||||
Logger.Reset();
|
||||
FilenameEvaluator.Reset();
|
||||
Striker.Reset();
|
||||
DryRunInterceptor.Reset();
|
||||
HardLinkFileService.Reset();
|
||||
HttpClientProvider.Reset();
|
||||
EventPublisher.Reset();
|
||||
RuleEvaluator.Reset();
|
||||
RuleManager.Reset();
|
||||
ClientWrapper.Reset();
|
||||
|
||||
DryRunInterceptor
|
||||
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns((Delegate action, object[] parameters) =>
|
||||
{
|
||||
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
+725
@@ -0,0 +1,725 @@
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
|
||||
{
|
||||
private readonly RTorrentServiceFixture _fixture;
|
||||
|
||||
public RTorrentServiceTests(RTorrentServiceFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.ResetMocks();
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : RTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_BasicScenarios(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentNotFound_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "nonexistent";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
|
||||
.ReturnsAsync((RTorrentTorrent?)null);
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentWithEmptyHash_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
|
||||
.ReturnsAsync(new RTorrentTorrent { Hash = "", Name = "Test" });
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentIsIgnored_ReturnsEmptyResult_WithFound()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
Label = "ignored-category",
|
||||
State = 1,
|
||||
Complete = 0
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { "ignored-category" });
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 1,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 1000,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.True(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 1000,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.IsPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizesHashToUppercase()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "lowercase-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync("LOWERCASE-HASH"))
|
||||
.ReturnsAsync((RTorrentTorrent?)null);
|
||||
|
||||
// Act
|
||||
await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.GetTorrentAsync("LOWERCASE-HASH"),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : RTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesSkipped_DeletesFromClient()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
|
||||
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 0 }
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeFilesWanted_DoesNotRemove()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 1000,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
|
||||
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // At least one wanted
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_FileErrorScenarios : RTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_FileErrorScenarios(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTorrentFilesThrows_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ThrowsAsync(new Exception("XML-RPC error"));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios : RTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_NotInDownloadingState_SkipsCheck()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// State=1, Complete=1 means seeding (not downloading)
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 1,
|
||||
DownRate = 100
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_ZeroSpeed_SkipsCheck()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// State=1, Complete=0 means downloading; DownRate=0 means zero speed
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 0,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// State=1, Complete=0 means downloading; DownRate > 0 means some speed
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 1000,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios : RTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_NotInStalledState_SkipsCheck()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// State=1, Complete=0, DownRate > 0 = downloading with speed (not stalled)
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 5000,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// State=1, Complete=0, DownRate=0 = stalled (downloading with no speed)
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 0,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
Assert.True(result.DeleteFromClient);
|
||||
}
|
||||
}
|
||||
|
||||
public class ShouldRemoveFromArrQueueAsync_IntegrationScenarios : RTorrentServiceTests
|
||||
{
|
||||
public ShouldRemoveFromArrQueueAsync_IntegrationScenarios(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowCheckPasses_ButStalledCheckFails_RemovesFromQueue()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
// State=1, Complete=0, DownRate=0 = stalled (not downloading, so slow check skipped)
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 0,
|
||||
SizeBytes = 1000,
|
||||
CompletedBytes = 500
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
// Slow check is skipped because speed is 0
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((true, DeleteReason.Stalled, true));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.True(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Never); // Skipped
|
||||
_fixture.RuleEvaluator.Verify(
|
||||
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothChecksPass_DoesNotRemove()
|
||||
{
|
||||
// Arrange
|
||||
const string hash = "TEST-HASH";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var download = new RTorrentTorrent
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
IsPrivate = 0,
|
||||
State = 1,
|
||||
Complete = 0,
|
||||
DownRate = 5000000, // Good speed
|
||||
SizeBytes = 10000000,
|
||||
CompletedBytes = 5000000
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentAsync(hash))
|
||||
.ReturnsAsync(download);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTrackersAsync(hash))
|
||||
.ReturnsAsync(new List<string>());
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.GetTorrentFilesAsync(hash))
|
||||
.ReturnsAsync(new List<RTorrentFile>
|
||||
{
|
||||
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
|
||||
.ReturnsAsync((false, DeleteReason.None, false));
|
||||
|
||||
// Act
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
// Assert
|
||||
Assert.False(result.ShouldRemove);
|
||||
Assert.Equal(DeleteReason.None, result.DeleteReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-87
@@ -303,44 +303,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { Id = 123, HashString = hash }
|
||||
}
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
var torrentInfo = new TorrentInfo { Id = 123, HashString = hash };
|
||||
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(torrentWrapper, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -354,37 +325,20 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "nonexistent-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
var torrentInfo = new TorrentInfo { Id = 456, HashString = hash };
|
||||
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync((TransmissionTorrents?)null);
|
||||
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(torrentWrapper, true);
|
||||
|
||||
// Assert - no exception thrown
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
|
||||
Times.Never);
|
||||
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -393,40 +347,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "test-hash";
|
||||
|
||||
var fields = new[]
|
||||
{
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE,
|
||||
TorrentFields.DOWNLOADED_EVER,
|
||||
TorrentFields.DOWNLOAD_DIR,
|
||||
TorrentFields.SECONDS_SEEDING,
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
};
|
||||
|
||||
var torrents = new TransmissionTorrents
|
||||
{
|
||||
Torrents = new[]
|
||||
{
|
||||
new TorrentInfo { Id = 123, HashString = hash }
|
||||
}
|
||||
};
|
||||
var torrentInfo = new TorrentInfo { Id = 123, HashString = hash };
|
||||
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.TorrentGetAsync(fields, hash))
|
||||
.ReturnsAsync(torrents);
|
||||
.Setup(x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(torrentWrapper, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
|
||||
+10
-3
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
@@ -290,13 +291,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -310,13 +313,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "UPPERCASE-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, true);
|
||||
await sut.DeleteDownload(mockTorrent.Object, true);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
@@ -330,13 +335,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
const string hash = "TEST-HASH";
|
||||
var mockTorrent = new Mock<ITorrentItemWrapper>();
|
||||
mockTorrent.Setup(x => x.Hash).Returns(hash);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sut.DeleteDownload(hash, false);
|
||||
await sut.DeleteDownload(mockTorrent.Object, false);
|
||||
|
||||
// Assert
|
||||
_fixture.ClientWrapper.Verify(
|
||||
|
||||
+3
-1
@@ -314,7 +314,8 @@ public static class TestDataContextFactory
|
||||
string name = "completed",
|
||||
double maxRatio = 1.0,
|
||||
double minSeedTime = 1.0,
|
||||
double maxSeedTime = -1)
|
||||
double maxSeedTime = -1,
|
||||
TorrentPrivacyType privacyType = TorrentPrivacyType.Both)
|
||||
{
|
||||
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
|
||||
var category = new SeedingRule
|
||||
@@ -324,6 +325,7 @@ public static class TestDataContextFactory
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
PrivacyType = privacyType,
|
||||
DeleteSourceFiles = true,
|
||||
DownloadCleanerConfigId = config.Id
|
||||
};
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateAccessToken(User user);
|
||||
string GenerateLoginToken(Guid userId);
|
||||
string GenerateRefreshToken();
|
||||
ClaimsPrincipal? ValidateAccessToken(string token);
|
||||
Guid? ValidateLoginToken(string token);
|
||||
byte[] GetOrCreateSigningKey();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hash);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed record PlexPinResult
|
||||
{
|
||||
public required int PinId { get; init; }
|
||||
public required string PinCode { get; init; }
|
||||
public required string AuthUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexPinCheckResult
|
||||
{
|
||||
public required bool Completed { get; init; }
|
||||
public string? AuthToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlexAccountInfo
|
||||
{
|
||||
public required string AccountId { get; init; }
|
||||
public required string Username { get; init; }
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
|
||||
public interface IPlexAuthService
|
||||
{
|
||||
Task<PlexPinResult> RequestPin();
|
||||
Task<PlexPinCheckResult> CheckPin(int pinId);
|
||||
Task<PlexAccountInfo> GetAccount(string authToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface ITotpService
|
||||
{
|
||||
string GenerateSecret();
|
||||
string GetQrCodeUri(string secret, string username);
|
||||
bool ValidateCode(string secret, string code);
|
||||
List<string> GenerateRecoveryCodes(int count = 10);
|
||||
string HashRecoveryCode(string code);
|
||||
bool VerifyRecoveryCode(string code, string hash);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class JwtService : IJwtService
|
||||
{
|
||||
private const string Issuer = "Cleanuparr";
|
||||
private const string Audience = "Cleanuparr";
|
||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly byte[] _signingKey;
|
||||
|
||||
public JwtService()
|
||||
{
|
||||
_signingKey = GetOrCreateSigningKey();
|
||||
}
|
||||
|
||||
public string GenerateAccessToken(User user)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("token_type", "access")
|
||||
};
|
||||
|
||||
return GenerateToken(claims, AccessTokenLifetime);
|
||||
}
|
||||
|
||||
public string GenerateLoginToken(Guid userId)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("token_type", "login")
|
||||
};
|
||||
|
||||
return GenerateToken(claims, LoginTokenLifetime);
|
||||
}
|
||||
|
||||
public string GenerateRefreshToken()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateAccessToken(string token)
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
if (principal is null) return null;
|
||||
|
||||
var tokenType = principal.FindFirst("token_type")?.Value;
|
||||
return tokenType == "access" ? principal : null;
|
||||
}
|
||||
|
||||
public Guid? ValidateLoginToken(string token)
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
if (principal is null) return null;
|
||||
|
||||
var tokenType = principal.FindFirst("token_type")?.Value;
|
||||
if (tokenType != "login") return null;
|
||||
|
||||
var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
return Guid.TryParse(userIdClaim, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
public byte[] GetOrCreateSigningKey()
|
||||
{
|
||||
var keyPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "jwt-key.bin");
|
||||
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
return File.ReadAllBytes(keyPath);
|
||||
}
|
||||
|
||||
var key = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(key);
|
||||
|
||||
var directory = Path.GetDirectoryName(keyPath);
|
||||
if (directory is not null && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllBytes(keyPath, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private string GenerateToken(Claim[] claims, TimeSpan lifetime)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(_signingKey);
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.Add(lifetime),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(_signingKey);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
return handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
}, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class PasswordService : IPasswordService
|
||||
{
|
||||
private const int WorkFactor = 12;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(password, hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class PlexAuthService : IPlexAuthService
|
||||
{
|
||||
private const string PlexApiBaseUrl = "https://plex.tv/api/v2";
|
||||
private const string PlexProduct = "Cleanuparr";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<PlexAuthService> _logger;
|
||||
private readonly string _clientIdentifier;
|
||||
|
||||
public PlexAuthService(IHttpClientFactory httpClientFactory, ILogger<PlexAuthService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("PlexAuth");
|
||||
_logger = logger;
|
||||
_clientIdentifier = GetOrCreateClientIdentifier();
|
||||
}
|
||||
|
||||
public async Task<PlexPinResult> RequestPin()
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
|
||||
AddPlexHeaders(request);
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["strong"] = "true"
|
||||
});
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
|
||||
|
||||
if (pin is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex PIN response");
|
||||
}
|
||||
|
||||
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
|
||||
|
||||
return new PlexPinResult
|
||||
{
|
||||
PinId = pin.Id,
|
||||
PinCode = pin.Code,
|
||||
AuthUrl = authUrl
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlexPinCheckResult> CheckPin(int pinId)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/pins/{pinId}");
|
||||
AddPlexHeaders(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new PlexPinCheckResult { Completed = false };
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
|
||||
|
||||
if (pin is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex PIN response");
|
||||
}
|
||||
|
||||
return new PlexPinCheckResult
|
||||
{
|
||||
Completed = !string.IsNullOrEmpty(pin.AuthToken),
|
||||
AuthToken = pin.AuthToken
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlexAccountInfo> GetAccount(string authToken)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/user");
|
||||
AddPlexHeaders(request);
|
||||
request.Headers.Add("X-Plex-Token", authToken);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var account = JsonSerializer.Deserialize<PlexAccountResponse>(json);
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Plex account response");
|
||||
}
|
||||
|
||||
return new PlexAccountInfo
|
||||
{
|
||||
AccountId = account.Id.ToString(),
|
||||
Username = account.Username,
|
||||
Email = account.Email
|
||||
};
|
||||
}
|
||||
|
||||
private void AddPlexHeaders(HttpRequestMessage request)
|
||||
{
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
request.Headers.Add("X-Plex-Client-Identifier", _clientIdentifier);
|
||||
request.Headers.Add("X-Plex-Product", PlexProduct);
|
||||
}
|
||||
|
||||
private static string GetOrCreateClientIdentifier()
|
||||
{
|
||||
var path = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "plex-client-id.txt");
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return File.ReadAllText(path).Trim();
|
||||
}
|
||||
|
||||
var clientId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (directory is not null && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(path, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
// JSON deserialization models
|
||||
private sealed class PlexPinResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("authToken")]
|
||||
public string? AuthToken { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PlexAccountResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using OtpNet;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class TotpService : ITotpService
|
||||
{
|
||||
private const string Issuer = "Cleanuparr";
|
||||
|
||||
public string GenerateSecret()
|
||||
{
|
||||
var key = KeyGeneration.GenerateRandomKey(20);
|
||||
return Base32Encoding.ToString(key);
|
||||
}
|
||||
|
||||
public string GetQrCodeUri(string secret, string username)
|
||||
{
|
||||
return $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(username)}?secret={secret}&issuer={Uri.EscapeDataString(Issuer)}&digits=6&period=30";
|
||||
}
|
||||
|
||||
public bool ValidateCode(string secret, string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code) || code.Length != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var keyBytes = Base32Encoding.ToBytes(secret);
|
||||
var totp = new Totp(keyBytes);
|
||||
return totp.VerifyTotp(code, out _, new VerificationWindow(previous: 1, future: 1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<string> GenerateRecoveryCodes(int count = 10)
|
||||
{
|
||||
var codes = new List<string>(count);
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
// Generate 8-character alphanumeric codes in format XXXX-XXXX
|
||||
var bytes = new byte[5];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
var code = Convert.ToHexString(bytes)[..8].ToUpperInvariant();
|
||||
codes.Add($"{code[..4]}-{code[4..]}");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
public string HashRecoveryCode(string code)
|
||||
{
|
||||
// Normalize: remove dashes and uppercase
|
||||
var normalized = code.Replace("-", "").ToUpperInvariant();
|
||||
return BCrypt.Net.BCrypt.HashPassword(normalized, 10);
|
||||
}
|
||||
|
||||
public bool VerifyRecoveryCode(string code, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = code.Replace("-", "").ToUpperInvariant();
|
||||
return BCrypt.Net.BCrypt.Verify(normalized, hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -44,6 +44,8 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper
|
||||
set => Info.Label = value;
|
||||
}
|
||||
|
||||
public string SavePath => Info.DownloadLocation ?? string.Empty;
|
||||
|
||||
public bool IsDownloading() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
|
||||
|
||||
public bool IsStalled() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && Info is { DownloadSpeed: <= 0, Eta: <= 0 };
|
||||
|
||||
+4
-10
@@ -37,9 +37,11 @@ public partial class DelugeService
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
{
|
||||
await DeleteDownload(torrent.Hash, deleteSourceFiles);
|
||||
string hash = torrent.Hash.ToLowerInvariant();
|
||||
|
||||
await _client.DeleteTorrents([hash], deleteSourceFiles);
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
@@ -139,14 +141,6 @@ public partial class DelugeService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
await _client.DeleteTorrents([hash], deleteSourceFiles);
|
||||
}
|
||||
|
||||
protected async Task CreateLabel(string name)
|
||||
{
|
||||
await _client.CreateLabel(name);
|
||||
|
||||
@@ -66,9 +66,6 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task DeleteDownload(string hash, bool deleteSourceFiles);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
|
||||
|
||||
@@ -94,21 +91,20 @@ public abstract class DownloadService : IDownloadService
|
||||
}
|
||||
|
||||
SeedingRule? category = seedingRules
|
||||
.FirstOrDefault(x => (torrent.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
.FirstOrDefault(x =>
|
||||
(torrent.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
x.PrivacyType switch
|
||||
{
|
||||
TorrentPrivacyType.Public => !torrent.IsPrivate,
|
||||
TorrentPrivacyType.Private => torrent.IsPrivate,
|
||||
_ => true
|
||||
});
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
if (!downloadCleanerConfig.DeletePrivate && torrent.IsPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", torrent.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
|
||||
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
|
||||
@@ -123,7 +119,7 @@ public abstract class DownloadService : IDownloadService
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(() => DeleteDownloadInternal(torrent, category.DeleteSourceFiles));
|
||||
await _dryRunInterceptor.InterceptAsync(() => DeleteDownload(torrent, category.DeleteSourceFiles));
|
||||
|
||||
_logger.LogInformation(
|
||||
"download cleaned | {reason} reached | delete files: {deleteFiles} | {name}",
|
||||
@@ -153,7 +149,7 @@ public abstract class DownloadService : IDownloadService
|
||||
/// </summary>
|
||||
/// <param name="torrent">The torrent to delete</param>
|
||||
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent</param>
|
||||
protected abstract Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles);
|
||||
public abstract Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles);
|
||||
|
||||
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, SeedingRule category)
|
||||
{
|
||||
@@ -245,4 +241,56 @@ public abstract class DownloadService : IDownloadService
|
||||
// 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;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete directory: {path}", path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete file: {path}", path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogTrace("File path to delete not found: {path}", path);
|
||||
|
||||
if (failOnNotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+25
@@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
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 TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
|
||||
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
|
||||
|
||||
@@ -54,6 +55,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
|
||||
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
|
||||
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
|
||||
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
|
||||
DownloadClientTypeName.rTorrent => CreateRTorrentService(downloadClientConfig),
|
||||
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
|
||||
};
|
||||
}
|
||||
@@ -151,4 +153,27 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
private RTorrentService CreateRTorrentService(DownloadClientConfig downloadClientConfig)
|
||||
{
|
||||
var logger = _serviceProvider.GetRequiredService<ILogger<RTorrentService>>();
|
||||
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
|
||||
var striker = _serviceProvider.GetRequiredService<IStriker>();
|
||||
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
|
||||
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
|
||||
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
|
||||
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
|
||||
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
|
||||
|
||||
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
|
||||
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
|
||||
|
||||
// Create the RTorrentService instance
|
||||
RTorrentService service = new(
|
||||
logger, filenameEvaluator, striker, dryRunInterceptor,
|
||||
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
);
|
||||
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -62,9 +62,9 @@ public interface IDownloadService : IDisposable
|
||||
/// <summary>
|
||||
/// Deletes a download item.
|
||||
/// </summary>
|
||||
/// <param name="hash">The torrent hash.</param>
|
||||
/// <param name="item">The torrent item.</param>
|
||||
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent. Defaults to true.</param>
|
||||
public Task DeleteDownload(string hash, bool deleteSourceFiles);
|
||||
public Task DeleteDownload(ITorrentItemWrapper item, bool deleteSourceFiles);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a category.
|
||||
|
||||
+2
@@ -47,6 +47,8 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper
|
||||
set => Info.Category = value;
|
||||
}
|
||||
|
||||
public string SavePath => Info.SavePath ?? string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Tags => Info.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
public bool IsDownloading() => Info.State is TorrentState.Downloading or TorrentState.ForcedDownload;
|
||||
|
||||
+2
-8
@@ -61,9 +61,9 @@ public partial class QBitService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
{
|
||||
await DeleteDownload(torrent.Hash, deleteSourceFiles);
|
||||
await _client.DeleteAsync([torrent.Hash], deleteSourceFiles);
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
@@ -172,12 +172,6 @@ public partial class QBitService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
|
||||
{
|
||||
await _client.DeleteAsync([hash], deleteDownloadedData: deleteSourceFiles);
|
||||
}
|
||||
|
||||
protected async Task CreateCategory(string name)
|
||||
{
|
||||
await _client.AddCategoryAsync(name);
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public interface IRTorrentClientWrapper
|
||||
{
|
||||
Task<string> GetVersionAsync();
|
||||
Task<List<RTorrentTorrent>> GetAllTorrentsAsync();
|
||||
Task<RTorrentTorrent?> GetTorrentAsync(string hash);
|
||||
Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash);
|
||||
Task<List<string>> GetTrackersAsync(string hash);
|
||||
Task DeleteTorrentAsync(string hash);
|
||||
Task SetFilePriorityAsync(string hash, int fileIndex, int priority);
|
||||
Task<string?> GetLabelAsync(string hash);
|
||||
Task SetLabelAsync(string hash, string label);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public interface IRTorrentService : IDownloadService
|
||||
{
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
/// <summary>
|
||||
/// Low-level XML-RPC client for communicating with rTorrent
|
||||
/// </summary>
|
||||
public sealed class RTorrentClient
|
||||
{
|
||||
private readonly DownloadClientConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
// Fields to request when fetching torrent data via d.multicall2
|
||||
private static readonly string[] TorrentFields =
|
||||
[
|
||||
"d.hash=",
|
||||
"d.name=",
|
||||
"d.is_private=",
|
||||
"d.size_bytes=",
|
||||
"d.completed_bytes=",
|
||||
"d.down.rate=",
|
||||
"d.ratio=",
|
||||
"d.state=",
|
||||
"d.complete=",
|
||||
"d.timestamp.finished=",
|
||||
"d.custom1=",
|
||||
"d.base_path="
|
||||
];
|
||||
|
||||
// Fields to request when fetching file data via f.multicall
|
||||
private static readonly string[] FileFields =
|
||||
[
|
||||
"f.path=",
|
||||
"f.size_bytes=",
|
||||
"f.priority=",
|
||||
"f.completed_chunks=",
|
||||
"f.size_chunks="
|
||||
];
|
||||
|
||||
public RTorrentClient(DownloadClientConfig config, HttpClient httpClient)
|
||||
{
|
||||
_config = config;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rTorrent client version for health check
|
||||
/// </summary>
|
||||
public async Task<string> GetVersionAsync()
|
||||
{
|
||||
var response = await CallAsync("system.client_version");
|
||||
return ParseStringValue(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all torrents with their status information
|
||||
/// </summary>
|
||||
public async Task<List<RTorrentTorrent>> GetAllTorrentsAsync()
|
||||
{
|
||||
var args = new object[] { "", "main" }.Concat(TorrentFields.Cast<object>()).ToArray();
|
||||
var response = await CallAsync("d.multicall2", args);
|
||||
return ParseTorrentList(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single torrent by hash
|
||||
/// </summary>
|
||||
public async Task<RTorrentTorrent?> GetTorrentAsync(string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fields = TorrentFields.Select(f => f.TrimEnd('=')).ToArray();
|
||||
var tasks = fields.Select(field => CallAsync(field, hash)).ToArray();
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
var values = responses.Select(ParseSingleValue).ToArray();
|
||||
|
||||
return CreateTorrentFromValues(values);
|
||||
}
|
||||
catch (RTorrentClientException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files for a torrent
|
||||
/// </summary>
|
||||
public async Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash)
|
||||
{
|
||||
var args = new object[] { hash, "" }.Concat(FileFields.Cast<object>()).ToArray();
|
||||
var response = await CallAsync("f.multicall", args);
|
||||
return ParseFileList(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracker URLs for a torrent
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetTrackersAsync(string hash)
|
||||
{
|
||||
var response = await CallAsync("t.multicall", hash, "", "t.url=");
|
||||
return ParseTrackerList(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a torrent from rTorrent
|
||||
/// </summary>
|
||||
/// <param name="hash">Torrent hash</param>
|
||||
public async Task DeleteTorrentAsync(string hash)
|
||||
{
|
||||
await CallAsync("d.erase", hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the priority for a file within a torrent
|
||||
/// </summary>
|
||||
/// <param name="hash">Torrent hash</param>
|
||||
/// <param name="fileIndex">File index (0-based)</param>
|
||||
/// <param name="priority">Priority: 0=skip, 1=normal, 2=high</param>
|
||||
public async Task SetFilePriorityAsync(string hash, int fileIndex, int priority)
|
||||
{
|
||||
// rTorrent uses hash:f<index> format for file commands
|
||||
await CallAsync("f.priority.set", $"{hash}:f{fileIndex}", priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the label (category) for a torrent
|
||||
/// </summary>
|
||||
public async Task<string?> GetLabelAsync(string hash)
|
||||
{
|
||||
var response = await CallAsync("d.custom1", hash);
|
||||
var label = ParseStringValue(response);
|
||||
return string.IsNullOrEmpty(label) ? null : label;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the label (category) for a torrent
|
||||
/// </summary>
|
||||
public async Task SetLabelAsync(string hash, string label)
|
||||
{
|
||||
await CallAsync("d.custom1.set", hash, label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an XML-RPC call to rTorrent
|
||||
/// </summary>
|
||||
private async Task<XElement> CallAsync(string method, params object[] parameters)
|
||||
{
|
||||
var requestXml = BuildXmlRpcRequest(method, parameters);
|
||||
var responseXml = await SendRequestAsync(requestXml);
|
||||
return ParseXmlRpcResponse(responseXml);
|
||||
}
|
||||
|
||||
private string BuildXmlRpcRequest(string method, object[] parameters)
|
||||
{
|
||||
var doc = new XDocument(
|
||||
new XElement("methodCall",
|
||||
new XElement("methodName", method),
|
||||
new XElement("params",
|
||||
parameters.Select(p => new XElement("param", SerializeValue(p)))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return doc.ToString(SaveOptions.DisableFormatting);
|
||||
}
|
||||
|
||||
private XElement SerializeValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => new XElement("value", new XElement("string", "")),
|
||||
string s => new XElement("value", new XElement("string", s)),
|
||||
int i => new XElement("value", new XElement("i4", i)),
|
||||
long l => new XElement("value", new XElement("i8", l)),
|
||||
bool b => new XElement("value", new XElement("boolean", b ? "1" : "0")),
|
||||
double d => new XElement("value", new XElement("double", d)),
|
||||
string[] arr => new XElement("value",
|
||||
new XElement("array",
|
||||
new XElement("data",
|
||||
arr.Select(item => new XElement("value", new XElement("string", item)))
|
||||
)
|
||||
)
|
||||
),
|
||||
object[] arr => new XElement("value",
|
||||
new XElement("array",
|
||||
new XElement("data",
|
||||
arr.Select(item => SerializeValue(item))
|
||||
)
|
||||
)
|
||||
),
|
||||
_ => new XElement("value", new XElement("string", value.ToString()))
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> SendRequestAsync(string requestXml)
|
||||
{
|
||||
var content = new StringContent(requestXml, Encoding.UTF8, "text/xml");
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("text/xml");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, _config.Url) { Content = content };
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.Username))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password ?? ""}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
private XElement ParseXmlRpcResponse(string responseXml)
|
||||
{
|
||||
var doc = XDocument.Parse(responseXml);
|
||||
var root = doc.Root;
|
||||
|
||||
if (root == null)
|
||||
{
|
||||
throw new RTorrentClientException("Invalid XML-RPC response: empty document");
|
||||
}
|
||||
|
||||
// Check for fault response
|
||||
var fault = root.Element("fault");
|
||||
if (fault != null)
|
||||
{
|
||||
var faultValue = fault.Element("value");
|
||||
var faultStruct = faultValue?.Element("struct");
|
||||
var faultString = faultStruct?.Elements("member")
|
||||
.FirstOrDefault(m => m.Element("name")?.Value == "faultString")
|
||||
?.Element("value")?.Value ?? "Unknown XML-RPC fault";
|
||||
|
||||
throw new RTorrentClientException($"XML-RPC fault: {faultString}");
|
||||
}
|
||||
|
||||
// Get the response value
|
||||
var paramsElement = root.Element("params");
|
||||
var param = paramsElement?.Element("param");
|
||||
var value = param?.Element("value");
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
throw new RTorrentClientException("Invalid XML-RPC response: missing value");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ParseStringValue(XElement value)
|
||||
{
|
||||
// Value can be directly text or wrapped in <string>, <i4>, <i8>, etc.
|
||||
var stringEl = value.Element("string");
|
||||
if (stringEl != null) return stringEl.Value;
|
||||
|
||||
var i4El = value.Element("i4");
|
||||
if (i4El != null) return i4El.Value;
|
||||
|
||||
var i8El = value.Element("i8");
|
||||
if (i8El != null) return i8El.Value;
|
||||
|
||||
// Direct text content
|
||||
if (!value.HasElements) return value.Value;
|
||||
|
||||
return value.Elements().First().Value;
|
||||
}
|
||||
|
||||
private static object? ParseSingleValue(XElement value)
|
||||
{
|
||||
var stringEl = value.Element("string");
|
||||
if (stringEl != null) return stringEl.Value;
|
||||
|
||||
var i4El = value.Element("i4");
|
||||
if (i4El != null) return long.TryParse(i4El.Value, out var i4) ? i4 : 0L;
|
||||
|
||||
var i8El = value.Element("i8");
|
||||
if (i8El != null) return long.TryParse(i8El.Value, out var i8) ? i8 : 0L;
|
||||
|
||||
var intEl = value.Element("int");
|
||||
if (intEl != null) return long.TryParse(intEl.Value, out var intVal) ? intVal : 0L;
|
||||
|
||||
var boolEl = value.Element("boolean");
|
||||
if (boolEl != null) return boolEl.Value == "1";
|
||||
|
||||
var doubleEl = value.Element("double");
|
||||
if (doubleEl != null) return double.TryParse(doubleEl.Value, out var d) ? d : 0.0;
|
||||
|
||||
// Direct text content
|
||||
if (!value.HasElements) return value.Value;
|
||||
|
||||
return value.Elements().First().Value;
|
||||
}
|
||||
|
||||
private List<RTorrentTorrent> ParseTorrentList(XElement value)
|
||||
{
|
||||
var result = new List<RTorrentTorrent>();
|
||||
var array = value.Element("array");
|
||||
var data = array?.Element("data");
|
||||
|
||||
if (data == null) return result;
|
||||
|
||||
foreach (var itemValue in data.Elements("value"))
|
||||
{
|
||||
var innerArray = itemValue.Element("array")?.Element("data");
|
||||
if (innerArray == null) continue;
|
||||
|
||||
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
|
||||
var torrent = CreateTorrentFromValues(values);
|
||||
if (torrent != null)
|
||||
{
|
||||
result.Add(torrent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static RTorrentTorrent? CreateTorrentFromValues(object?[] values)
|
||||
{
|
||||
if (values.Length < 12) return null;
|
||||
|
||||
return new RTorrentTorrent
|
||||
{
|
||||
Hash = values[0]?.ToString() ?? "",
|
||||
Name = values[1]?.ToString() ?? "",
|
||||
IsPrivate = Convert.ToInt32(values[2] ?? 0),
|
||||
SizeBytes = Convert.ToInt64(values[3] ?? 0),
|
||||
CompletedBytes = Convert.ToInt64(values[4] ?? 0),
|
||||
DownRate = Convert.ToInt64(values[5] ?? 0),
|
||||
Ratio = Convert.ToInt64(values[6] ?? 0),
|
||||
State = Convert.ToInt32(values[7] ?? 0),
|
||||
Complete = Convert.ToInt32(values[8] ?? 0),
|
||||
TimestampFinished = Convert.ToInt64(values[9] ?? 0),
|
||||
Label = values[10]?.ToString(),
|
||||
BasePath = values[11]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private List<RTorrentFile> ParseFileList(XElement value)
|
||||
{
|
||||
var result = new List<RTorrentFile>();
|
||||
var array = value.Element("array");
|
||||
var data = array?.Element("data");
|
||||
|
||||
if (data == null) return result;
|
||||
|
||||
int index = 0;
|
||||
foreach (var itemValue in data.Elements("value"))
|
||||
{
|
||||
var innerArray = itemValue.Element("array")?.Element("data");
|
||||
if (innerArray == null) continue;
|
||||
|
||||
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
|
||||
if (values.Length >= 5)
|
||||
{
|
||||
result.Add(new RTorrentFile
|
||||
{
|
||||
Index = index,
|
||||
Path = values[0]?.ToString() ?? "",
|
||||
SizeBytes = Convert.ToInt64(values[1] ?? 0),
|
||||
Priority = Convert.ToInt32(values[2] ?? 1),
|
||||
CompletedChunks = Convert.ToInt64(values[3] ?? 0),
|
||||
SizeChunks = Convert.ToInt64(values[4] ?? 0)
|
||||
});
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<string> ParseTrackerList(XElement value)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var array = value.Element("array");
|
||||
var data = array?.Element("data");
|
||||
|
||||
if (data == null) return result;
|
||||
|
||||
foreach (var itemValue in data.Elements("value"))
|
||||
{
|
||||
var innerArray = itemValue.Element("array")?.Element("data");
|
||||
if (innerArray == null) continue;
|
||||
|
||||
var url = innerArray.Elements("value").FirstOrDefault();
|
||||
if (url != null)
|
||||
{
|
||||
var trackerUrl = ParseStringValue(url);
|
||||
if (!string.IsNullOrEmpty(trackerUrl))
|
||||
{
|
||||
result.Add(trackerUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public sealed class RTorrentClientWrapper : IRTorrentClientWrapper
|
||||
{
|
||||
private readonly RTorrentClient _client;
|
||||
|
||||
public RTorrentClientWrapper(RTorrentClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public Task<string> GetVersionAsync()
|
||||
=> _client.GetVersionAsync();
|
||||
|
||||
public Task<List<RTorrentTorrent>> GetAllTorrentsAsync()
|
||||
=> _client.GetAllTorrentsAsync();
|
||||
|
||||
public Task<RTorrentTorrent?> GetTorrentAsync(string hash)
|
||||
=> _client.GetTorrentAsync(hash);
|
||||
|
||||
public Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash)
|
||||
=> _client.GetTorrentFilesAsync(hash);
|
||||
|
||||
public Task<List<string>> GetTrackersAsync(string hash)
|
||||
=> _client.GetTrackersAsync(hash);
|
||||
|
||||
public Task DeleteTorrentAsync(string hash)
|
||||
=> _client.DeleteTorrentAsync(hash);
|
||||
|
||||
public Task SetFilePriorityAsync(string hash, int fileIndex, int priority)
|
||||
=> _client.SetFilePriorityAsync(hash, fileIndex, priority);
|
||||
|
||||
public Task<string?> GetLabelAsync(string hash)
|
||||
=> _client.GetLabelAsync(hash);
|
||||
|
||||
public Task SetLabelAsync(string hash, string label)
|
||||
=> _client.SetLabelAsync(hash, label);
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for RTorrentTorrent that implements ITorrentItemWrapper interface
|
||||
/// </summary>
|
||||
public sealed class RTorrentItemWrapper : ITorrentItemWrapper
|
||||
{
|
||||
public RTorrentTorrent Info { get; }
|
||||
private readonly IReadOnlyList<string> _trackers;
|
||||
private string? _category;
|
||||
|
||||
public RTorrentItemWrapper(RTorrentTorrent torrent, IReadOnlyList<string>? trackers = null)
|
||||
{
|
||||
Info = torrent ?? throw new ArgumentNullException(nameof(torrent));
|
||||
_trackers = trackers ?? torrent.Trackers ?? [];
|
||||
_category = torrent.Label;
|
||||
}
|
||||
|
||||
public string Hash => Info.Hash;
|
||||
|
||||
public string Name => Info.Name;
|
||||
|
||||
public bool IsPrivate => Info.IsPrivate == 1;
|
||||
|
||||
public long Size => Info.SizeBytes;
|
||||
|
||||
public double CompletionPercentage => Info.SizeBytes > 0
|
||||
? (Info.CompletedBytes / (double)Info.SizeBytes) * 100.0
|
||||
: 0.0;
|
||||
|
||||
public long DownloadedBytes => Info.CompletedBytes;
|
||||
|
||||
public long DownloadSpeed => Info.DownRate;
|
||||
|
||||
/// <summary>
|
||||
/// Ratio from rTorrent (returned as ratio * 1000, so divide by 1000)
|
||||
/// </summary>
|
||||
public double Ratio => Info.Ratio / 1000.0;
|
||||
|
||||
public long Eta => CalculateEta();
|
||||
|
||||
public long SeedingTimeSeconds => CalculateSeedingTime();
|
||||
|
||||
public string? Category
|
||||
{
|
||||
get => _category;
|
||||
set => _category = value;
|
||||
}
|
||||
|
||||
public string SavePath => Info.BasePath ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Downloading when state is 1 (started) and complete is 0 (not finished)
|
||||
/// </summary>
|
||||
public bool IsDownloading() => Info.State == 1 && Info.Complete == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Stalled when downloading but no download speed and no ETA
|
||||
/// </summary>
|
||||
public bool IsStalled() => IsDownloading() && Info.DownRate <= 0 && Eta <= 0;
|
||||
|
||||
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (ignoredDownloads.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_trackers.Any(url => UriService.GetDomain(url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate ETA based on remaining bytes and download speed
|
||||
/// </summary>
|
||||
private long CalculateEta()
|
||||
{
|
||||
if (Info.DownRate <= 0) return 0;
|
||||
long remaining = Info.SizeBytes - Info.CompletedBytes;
|
||||
if (remaining <= 0) return 0;
|
||||
return remaining / Info.DownRate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate seeding time based on the timestamp when the torrent finished downloading.
|
||||
/// rTorrent doesn't natively track seeding time, so we calculate it from completion timestamp.
|
||||
/// </summary>
|
||||
private long CalculateSeedingTime()
|
||||
{
|
||||
// If not finished yet, no seeding time
|
||||
if (Info.Complete != 1 || Info.TimestampFinished <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var seedingTime = now - Info.TimestampFinished;
|
||||
return seedingTime > 0 ? seedingTime : 0;
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using Cleanuparr.Domain.Entities.HealthCheck;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
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 Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public partial class RTorrentService : DownloadService, IRTorrentService
|
||||
{
|
||||
private readonly IRTorrentClientWrapper _client;
|
||||
|
||||
public RTorrentService(
|
||||
ILogger<RTorrentService> logger,
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService,
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
IEventPublisher eventPublisher,
|
||||
IBlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager
|
||||
) : base(
|
||||
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
)
|
||||
{
|
||||
var rtorrentClient = new RTorrentClient(downloadClientConfig, _httpClient);
|
||||
_client = new RTorrentClientWrapper(rtorrentClient);
|
||||
}
|
||||
|
||||
// Internal constructor for testing
|
||||
internal RTorrentService(
|
||||
ILogger<RTorrentService> logger,
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService,
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
IEventPublisher eventPublisher,
|
||||
IBlocklistProvider blocklistProvider,
|
||||
DownloadClientConfig downloadClientConfig,
|
||||
IRuleEvaluator ruleEvaluator,
|
||||
IRuleManager ruleManager,
|
||||
IRTorrentClientWrapper clientWrapper
|
||||
) : base(
|
||||
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
|
||||
)
|
||||
{
|
||||
_client = clientWrapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// rTorrent uses HTTP Basic Auth (typically via reverse proxy).
|
||||
/// Credentials are sent automatically with each request when configured.
|
||||
/// </summary>
|
||||
public override Task LoginAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task<HealthCheckResult> HealthCheckAsync()
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get the version - this is a simple health check
|
||||
var version = await _client.GetVersionAsync();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogDebug("Health check: rTorrent version {version} for client {clientId}", version, _downloadClientConfig.Id);
|
||||
|
||||
return new HealthCheckResult
|
||||
{
|
||||
IsHealthy = true,
|
||||
ResponseTime = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogWarning(ex, "Health check failed for rTorrent client {clientId}", _downloadClientConfig.Id);
|
||||
|
||||
return new HealthCheckResult
|
||||
{
|
||||
IsHealthy = false,
|
||||
ErrorMessage = $"Connection failed: {ex.Message}",
|
||||
ResponseTime = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public partial class RTorrentService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
// rTorrent uses uppercase hashes
|
||||
hash = hash.ToUpperInvariant();
|
||||
|
||||
RTorrentTorrent? download = await _client.GetTorrentAsync(hash);
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = download.IsPrivate == 1;
|
||||
result.Found = true;
|
||||
|
||||
// Get trackers for ignore check
|
||||
var trackers = await _client.GetTrackersAsync(hash);
|
||||
var torrentWrapper = new RTorrentItemWrapper(download, trackers);
|
||||
|
||||
if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
|
||||
{
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<RTorrentFile> files;
|
||||
|
||||
try
|
||||
{
|
||||
files = await _client.GetTorrentFilesAsync(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
bool hasPriorityUpdates = false;
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
List<(int Index, int Priority)> priorityUpdates = [];
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
totalFiles++;
|
||||
string fileName = Path.GetFileName(file.Path);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(fileName, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
}
|
||||
|
||||
if (file.Priority == 0)
|
||||
{
|
||||
_logger.LogTrace("File is already skipped | {file}", file.Path);
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_filenameEvaluator.IsValid(fileName, blocklistType, patterns, regexes))
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
hasPriorityUpdates = true;
|
||||
priorityUpdates.Add((file.Index, 0));
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogTrace("File is valid | {file}", file.Path);
|
||||
}
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
_logger.LogDebug("All files are blocked for {name}", download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesBlocked;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", priorityUpdates.Count, download.Name);
|
||||
|
||||
foreach (var (index, priority) in priorityUpdates)
|
||||
{
|
||||
await _dryRunInterceptor.InterceptAsync(SetFilePriority, hash, index, priority);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected virtual async Task SetFilePriority(string hash, int index, int priority)
|
||||
{
|
||||
await _client.SetFilePriorityAsync(hash, index, priority);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public partial class RTorrentService
|
||||
{
|
||||
public override async Task<List<ITorrentItemWrapper>> GetSeedingDownloads()
|
||||
{
|
||||
var downloads = await _client.GetAllTorrentsAsync();
|
||||
|
||||
return downloads
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
// Seeding: complete=1 (finished) and state=1 (started)
|
||||
.Where(x => x is { Complete: 1, State: 1 })
|
||||
.Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
|
||||
downloads
|
||||
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
{
|
||||
string hash = torrent.Hash.ToUpperInvariant();
|
||||
await _client.DeleteTorrentAsync(hash);
|
||||
|
||||
if (deleteSourceFiles)
|
||||
{
|
||||
if (!TryDeleteFiles(torrent.SavePath, true))
|
||||
{
|
||||
_logger.LogWarning("Failed to delete files | {name}", torrent.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// rTorrent doesn't have native category management. Labels are stored in d.custom1
|
||||
/// and are created implicitly when set. This is a no-op.
|
||||
/// </summary>
|
||||
public override Task CreateCategoryAsync(string name)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
|
||||
|
||||
foreach (RTorrentItemWrapper torrent in downloads.Cast<RTorrentItemWrapper>())
|
||||
{
|
||||
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);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
|
||||
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
|
||||
|
||||
List<RTorrentFile> files;
|
||||
try
|
||||
{
|
||||
files = await _client.GetTorrentFilesAsync(torrent.Hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent files for {name}", torrent.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasHardlinks = false;
|
||||
bool hasErrors = false;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar,
|
||||
Path.Combine(torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
long hardlinkCount = _hardLinkFileService
|
||||
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", torrent.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetLabelAsync(hash, newLabel);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.RTorrent.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
|
||||
|
||||
public partial class RTorrentService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
// rTorrent uses uppercase hashes
|
||||
hash = hash.ToUpperInvariant();
|
||||
|
||||
DownloadCheckResult result = new();
|
||||
|
||||
RTorrentTorrent? download = await _client.GetTorrentAsync(hash);
|
||||
|
||||
if (string.IsNullOrEmpty(download?.Hash))
|
||||
{
|
||||
_logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = download.IsPrivate == 1;
|
||||
result.Found = true;
|
||||
|
||||
// Get trackers for ignore check
|
||||
var trackers = await _client.GetTrackersAsync(hash);
|
||||
RTorrentItemWrapper torrent = new(download, trackers);
|
||||
|
||||
if (torrent.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", torrent.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<RTorrentFile> files;
|
||||
try
|
||||
{
|
||||
files = await _client.GetTorrentFilesAsync(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", torrent.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if all files are skipped (priority = 0)
|
||||
bool hasActiveFiles = files.Any(f => f.Priority > 0);
|
||||
|
||||
if (files.Count > 0 && !hasActiveFiles)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
_logger.LogTrace("all files are unwanted | removing download | {name}", torrent.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
result.DeleteFromClient = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(wrapper);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> 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);
|
||||
}
|
||||
|
||||
if (wrapper.DownloadSpeed <= 0)
|
||||
{
|
||||
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
|
||||
return (false, DeleteReason.None, false);
|
||||
}
|
||||
|
||||
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> 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);
|
||||
}
|
||||
|
||||
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
|
||||
}
|
||||
}
|
||||
+2
@@ -46,6 +46,8 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper
|
||||
get => Info.GetCategory();
|
||||
set => Info.AppendCategory(value);
|
||||
}
|
||||
|
||||
public string SavePath => Info.DownloadDir ?? string.Empty;
|
||||
|
||||
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
|
||||
public bool IsDownloading() => Info.Status == 4;
|
||||
|
||||
+2
-20
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
@@ -39,10 +38,10 @@ public partial class TransmissionService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
{
|
||||
var transmissionTorrent = (TransmissionItemWrapper)torrent;
|
||||
await RemoveDownloadAsync(transmissionTorrent.Info.Id, deleteSourceFiles);
|
||||
await _client.TorrentRemoveAsync([transmissionTorrent.Info.Id], deleteSourceFiles);
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
@@ -137,21 +136,4 @@ public partial class TransmissionService
|
||||
{
|
||||
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
|
||||
}
|
||||
|
||||
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.TorrentRemoveAsync([torrent.Id], deleteSourceFiles);
|
||||
}
|
||||
|
||||
protected virtual async Task RemoveDownloadAsync(long downloadId, bool deleteSourceFiles)
|
||||
{
|
||||
await _client.TorrentRemoveAsync([downloadId], deleteSourceFiles);
|
||||
}
|
||||
}
|
||||
+2
@@ -45,6 +45,8 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper
|
||||
set => Info.Label = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
public string SavePath => Info.SavePath ?? string.Empty;
|
||||
|
||||
public bool IsDownloading() =>
|
||||
(Info.Status & UTorrentStatus.Started) != 0 &&
|
||||
(Info.Status & UTorrentStatus.Checked) != 0 &&
|
||||
|
||||
+3
-10
@@ -36,9 +36,10 @@ public partial class UTorrentService
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
|
||||
{
|
||||
await DeleteDownload(torrent.Hash, deleteSourceFiles);
|
||||
string hash = torrent.Hash.ToLowerInvariant();
|
||||
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
@@ -120,14 +121,6 @@ public partial class UTorrentService
|
||||
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
|
||||
}
|
||||
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the health status of an arr instance
|
||||
/// </summary>
|
||||
public class ArrHealthStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the instance ID
|
||||
/// </summary>
|
||||
public Guid InstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the instance name
|
||||
/// </summary>
|
||||
public string InstanceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the instance type (Sonarr, Radarr, etc.)
|
||||
/// </summary>
|
||||
public InstanceType InstanceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the instance is healthy
|
||||
/// </summary>
|
||||
public bool IsHealthy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time when the instance was last checked
|
||||
/// </summary>
|
||||
public DateTime LastChecked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message if the instance is not healthy
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks the health of all download clients
|
||||
/// Background service that periodically checks the health of all download clients and arr instances
|
||||
/// </summary>
|
||||
public class HealthCheckBackgroundService : BackgroundService
|
||||
{
|
||||
@@ -34,34 +34,32 @@ public class HealthCheckBackgroundService : BackgroundService
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Performing periodic health check for all download clients");
|
||||
|
||||
_logger.LogDebug("Performing periodic health check for all download clients and arr instances");
|
||||
|
||||
try
|
||||
{
|
||||
// Check health of all clients
|
||||
var results = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
|
||||
// Log summary
|
||||
var healthyCount = results.Count(r => r.Value.IsHealthy);
|
||||
var unhealthyCount = results.Count - healthyCount;
|
||||
// Check health of all download clients
|
||||
var clientResults = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
|
||||
if (unhealthyCount is 0)
|
||||
var clientHealthy = clientResults.Count(r => r.Value.IsHealthy);
|
||||
var clientUnhealthy = clientResults.Count - clientHealthy;
|
||||
|
||||
if (clientUnhealthy is 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy download clients",
|
||||
healthyCount,
|
||||
unhealthyCount);
|
||||
"Download client health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy",
|
||||
clientHealthy,
|
||||
clientUnhealthy);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy download clients",
|
||||
healthyCount,
|
||||
unhealthyCount);
|
||||
"Download client health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy",
|
||||
clientHealthy,
|
||||
clientUnhealthy);
|
||||
}
|
||||
|
||||
// Log detailed information for unhealthy clients
|
||||
foreach (var result in results.Where(r => !r.Value.IsHealthy))
|
||||
|
||||
foreach (var result in clientResults.Where(r => !r.Value.IsHealthy))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Download client {clientId} ({clientName}) is unhealthy: {errorMessage}",
|
||||
@@ -69,6 +67,36 @@ public class HealthCheckBackgroundService : BackgroundService
|
||||
result.Value.ClientName,
|
||||
result.Value.ErrorMessage);
|
||||
}
|
||||
|
||||
// Check health of all arr instances
|
||||
var arrResults = await _healthCheckService.CheckAllArrInstancesHealthAsync();
|
||||
|
||||
var arrHealthy = arrResults.Count(r => r.Value.IsHealthy);
|
||||
var arrUnhealthy = arrResults.Count - arrHealthy;
|
||||
|
||||
if (arrUnhealthy is 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Arr instance health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy",
|
||||
arrHealthy,
|
||||
arrUnhealthy);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Arr instance health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy",
|
||||
arrHealthy,
|
||||
arrUnhealthy);
|
||||
}
|
||||
|
||||
foreach (var result in arrResults.Where(r => !r.Value.IsHealthy))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Arr instance {instanceId} ({instanceName}) is unhealthy: {errorMessage}",
|
||||
result.Key,
|
||||
result.Value.InstanceName,
|
||||
result.Value.ErrorMessage);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -7,12 +8,13 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Service for checking the health of download clients
|
||||
/// Service for checking the health of download clients and arr instances
|
||||
/// </summary>
|
||||
public class HealthCheckService : IHealthCheckService
|
||||
{
|
||||
private readonly ILogger<HealthCheckService> _logger;
|
||||
private readonly Dictionary<Guid, HealthStatus> _healthStatuses = new();
|
||||
private readonly Dictionary<Guid, ArrHealthStatus> _arrHealthStatuses = new();
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly object _lockObject = new();
|
||||
|
||||
@@ -148,7 +150,192 @@ public class HealthCheckService : IHealthCheckService
|
||||
return new Dictionary<Guid, HealthStatus>(_healthStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ArrHealthStatus> CheckArrInstanceHealthAsync(Guid instanceId)
|
||||
{
|
||||
_logger.LogDebug("Checking health for arr instance {instanceId}", instanceId);
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
// Get the arr instance with its config (needed for InstanceType)
|
||||
// Load config with instances first, then find in memory (SQLite doesn't support APPLY)
|
||||
var config = await dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
|
||||
|
||||
var arrInstance = config is null ? null : new
|
||||
{
|
||||
Instance = config.Instances.First(i => i.Id == instanceId),
|
||||
Config = config
|
||||
};
|
||||
|
||||
if (arrInstance is null)
|
||||
{
|
||||
_logger.LogWarning("Arr instance {instanceId} not found in configuration", instanceId);
|
||||
var notFoundStatus = new ArrHealthStatus
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
IsHealthy = false,
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ErrorMessage = "Arr instance not found in configuration"
|
||||
};
|
||||
|
||||
UpdateArrHealthStatus(notFoundStatus);
|
||||
return notFoundStatus;
|
||||
}
|
||||
|
||||
// Get the arr client and execute health check
|
||||
var arrClientFactory = scope.ServiceProvider.GetRequiredService<IArrClientFactory>();
|
||||
var client = arrClientFactory.GetClient(arrInstance.Config.Type, arrInstance.Instance.Version);
|
||||
await client.HealthCheckAsync(arrInstance.Instance);
|
||||
|
||||
var status = new ArrHealthStatus
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
InstanceName = arrInstance.Instance.Name,
|
||||
InstanceType = arrInstance.Config.Type,
|
||||
IsHealthy = true,
|
||||
LastChecked = DateTime.UtcNow
|
||||
};
|
||||
|
||||
UpdateArrHealthStatus(status);
|
||||
return status;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error performing health check for arr instance {instanceId}", instanceId);
|
||||
|
||||
var status = new ArrHealthStatus
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
IsHealthy = false,
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ErrorMessage = $"Error: {ex.Message}"
|
||||
};
|
||||
|
||||
UpdateArrHealthStatus(status);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IDictionary<Guid, ArrHealthStatus>> CheckAllArrInstancesHealthAsync()
|
||||
{
|
||||
_logger.LogDebug("Checking health for all enabled arr instances");
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
// Get all enabled arr instances across all configs
|
||||
// Load configs with instances first, then flatten in memory (SQLite doesn't support APPLY)
|
||||
var configs = await dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.ToListAsync();
|
||||
|
||||
var enabledInstances = configs
|
||||
.SelectMany(c => c.Instances
|
||||
.Where(i => i.Enabled)
|
||||
.Select(i => new { Instance = i, Config = c }))
|
||||
.ToList();
|
||||
|
||||
var results = new Dictionary<Guid, ArrHealthStatus>();
|
||||
var arrClientFactory = scope.ServiceProvider.GetRequiredService<IArrClientFactory>();
|
||||
|
||||
foreach (var entry in enabledInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = arrClientFactory.GetClient(entry.Config.Type, entry.Instance.Version);
|
||||
await client.HealthCheckAsync(entry.Instance);
|
||||
|
||||
var status = new ArrHealthStatus
|
||||
{
|
||||
InstanceId = entry.Instance.Id,
|
||||
InstanceName = entry.Instance.Name,
|
||||
InstanceType = entry.Config.Type,
|
||||
IsHealthy = true,
|
||||
LastChecked = DateTime.UtcNow
|
||||
};
|
||||
|
||||
UpdateArrHealthStatus(status);
|
||||
results[entry.Instance.Id] = status;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error performing health check for arr instance {instanceId} ({instanceName})",
|
||||
entry.Instance.Id, entry.Instance.Name);
|
||||
|
||||
var status = new ArrHealthStatus
|
||||
{
|
||||
InstanceId = entry.Instance.Id,
|
||||
InstanceName = entry.Instance.Name,
|
||||
InstanceType = entry.Config.Type,
|
||||
IsHealthy = false,
|
||||
LastChecked = DateTime.UtcNow,
|
||||
ErrorMessage = $"Error: {ex.Message}"
|
||||
};
|
||||
|
||||
UpdateArrHealthStatus(status);
|
||||
results[entry.Instance.Id] = status;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for all arr instances");
|
||||
return new Dictionary<Guid, ArrHealthStatus>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ArrHealthStatus? GetArrInstanceHealth(Guid instanceId)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _arrHealthStatuses.TryGetValue(instanceId, out var status) ? status : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDictionary<Guid, ArrHealthStatus> GetAllArrInstanceHealth()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return new Dictionary<Guid, ArrHealthStatus>(_arrHealthStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateArrHealthStatus(ArrHealthStatus newStatus)
|
||||
{
|
||||
ArrHealthStatus? previousStatus;
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_arrHealthStatuses.TryGetValue(newStatus.InstanceId, out previousStatus);
|
||||
_arrHealthStatuses[newStatus.InstanceId] = newStatus;
|
||||
}
|
||||
|
||||
bool isStateChange = previousStatus == null ||
|
||||
previousStatus.IsHealthy != newStatus.IsHealthy;
|
||||
|
||||
if (isStateChange)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Arr instance {instanceId} ({instanceName}) health changed: {status}",
|
||||
newStatus.InstanceId,
|
||||
newStatus.InstanceName,
|
||||
newStatus.IsHealthy ? "Healthy" : "Unhealthy");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateHealthStatus(HealthStatus newStatus)
|
||||
{
|
||||
HealthStatus? previousStatus;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Cleanuparr.Infrastructure.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Service for checking the health of download clients
|
||||
/// Service for checking the health of download clients and arr instances
|
||||
/// </summary>
|
||||
public interface IHealthCheckService
|
||||
{
|
||||
@@ -9,30 +9,56 @@ public interface IHealthCheckService
|
||||
/// Occurs when a client's health status changes
|
||||
/// </summary>
|
||||
event EventHandler<ClientHealthChangedEventArgs> ClientHealthChanged;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of a specific client
|
||||
/// Checks the health of a specific download client
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client ID to check</param>
|
||||
/// <returns>The health status of the client</returns>
|
||||
Task<HealthStatus> CheckClientHealthAsync(Guid clientId);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of all enabled clients
|
||||
/// Checks the health of all enabled download clients
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of client IDs to health statuses</returns>
|
||||
Task<IDictionary<Guid, HealthStatus>> CheckAllClientsHealthAsync();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current health status of a client
|
||||
/// Gets the current health status of a download client
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client ID</param>
|
||||
/// <returns>The current health status, or null if the client hasn't been checked</returns>
|
||||
HealthStatus? GetClientHealth(Guid clientId);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current health status of all clients that have been checked
|
||||
/// Gets the current health status of all download clients that have been checked
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of client IDs to health statuses</returns>
|
||||
IDictionary<Guid, HealthStatus> GetAllClientHealth();
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of a specific arr instance
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The arr instance ID to check</param>
|
||||
/// <returns>The health status of the arr instance</returns>
|
||||
Task<ArrHealthStatus> CheckArrInstanceHealthAsync(Guid instanceId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of all enabled arr instances
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of instance IDs to health statuses</returns>
|
||||
Task<IDictionary<Guid, ArrHealthStatus>> CheckAllArrInstancesHealthAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current health status of an arr instance
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The arr instance ID</param>
|
||||
/// <returns>The current health status, or null if the instance hasn't been checked</returns>
|
||||
ArrHealthStatus? GetArrInstanceHealth(Guid instanceId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current health status of all arr instances that have been checked
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of instance IDs to health statuses</returns>
|
||||
IDictionary<Guid, ArrHealthStatus> GetAllArrInstanceHealth();
|
||||
}
|
||||
@@ -4,16 +4,12 @@ namespace Cleanuparr.Infrastructure.Helpers;
|
||||
|
||||
public static class CacheKeys
|
||||
{
|
||||
public static string Strike(StrikeType strikeType, string hash) => $"{strikeType.ToString()}_{hash}";
|
||||
|
||||
public static string BlocklistType(InstanceType instanceType) => $"{instanceType.ToString()}_type";
|
||||
public static string BlocklistPatterns(InstanceType instanceType) => $"{instanceType.ToString()}_patterns";
|
||||
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
|
||||
|
||||
|
||||
public static string KnownMalwarePatterns() => "KNOWN_MALWARE_PATTERNS";
|
||||
|
||||
public static string StrikeItem(string hash, StrikeType strikeType) => $"item_{hash}_{strikeType.ToString()}";
|
||||
|
||||
|
||||
public static string IgnoredDownloads(string name) => $"{name}_ignored";
|
||||
|
||||
public static string DownloadMarkedForRemoval(string hash, Uri url) => $"remove_{hash.ToLowerInvariant()}_{url}";
|
||||
|
||||
@@ -111,6 +111,7 @@ public static class LoggingConfigManager
|
||||
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.DataProtection", LogEventLevel.Error)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Cleanuparr.Infrastructure.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Service for aggregating application statistics
|
||||
/// </summary>
|
||||
public interface IStatsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets aggregated statistics for the given timeframe
|
||||
/// </summary>
|
||||
/// <param name="hours">Timeframe in hours (default 24)</param>
|
||||
/// <param name="includeEvents">Number of recent events to include (0 = none)</param>
|
||||
/// <param name="includeStrikes">Number of recent strikes to include (0 = none)</param>
|
||||
/// <returns>Aggregated stats response</returns>
|
||||
Task<StatsResponse> GetStatsAsync(int hours = 24, int includeEvents = 0, int includeStrikes = 0);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated application statistics for dashboard integrations
|
||||
/// </summary>
|
||||
public class StatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Event statistics within the timeframe
|
||||
/// </summary>
|
||||
public EventStats Events { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Strike statistics within the timeframe
|
||||
/// </summary>
|
||||
public StrikeStats Strikes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Job run statistics within the timeframe
|
||||
/// </summary>
|
||||
public JobStats Jobs { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current health status of download clients and arr instances
|
||||
/// </summary>
|
||||
public HealthStats Health { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When this response was generated
|
||||
/// </summary>
|
||||
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event statistics grouped by type and severity
|
||||
/// </summary>
|
||||
public class EventStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of events in the timeframe
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Events grouped by EventType
|
||||
/// </summary>
|
||||
public Dictionary<string, int> ByType { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Events grouped by severity level
|
||||
/// </summary>
|
||||
public Dictionary<string, int> BySeverity { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The timeframe in hours that these stats cover
|
||||
/// </summary>
|
||||
public int TimeframeHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Recent event items (only included when includeEvents > 0)
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<RecentEventDto>? RecentItems { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strike statistics
|
||||
/// </summary>
|
||||
public class StrikeStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of strikes in the timeframe
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Strikes grouped by StrikeType
|
||||
/// </summary>
|
||||
public Dictionary<string, int> ByType { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Number of download items removed in the timeframe
|
||||
/// </summary>
|
||||
public int ItemsRemoved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The timeframe in hours that these stats cover
|
||||
/// </summary>
|
||||
public int TimeframeHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Recent strike items (only included when includeStrikes > 0)
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<RecentStrikeDto>? RecentItems { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Job run statistics
|
||||
/// </summary>
|
||||
public class JobStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Job run stats grouped by JobType
|
||||
/// </summary>
|
||||
public Dictionary<string, JobTypeStats> ByType { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The timeframe in hours that these stats cover
|
||||
/// </summary>
|
||||
public int TimeframeHours { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for a specific job type
|
||||
/// </summary>
|
||||
public class JobTypeStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of runs in the timeframe
|
||||
/// </summary>
|
||||
public int TotalRuns { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of completed runs
|
||||
/// </summary>
|
||||
public int Completed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of failed runs
|
||||
/// </summary>
|
||||
public int Failed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the last job of this type ran
|
||||
/// </summary>
|
||||
public DateTime? LastRunAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this job is next scheduled to run
|
||||
/// </summary>
|
||||
public DateTime? NextRunAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health status summary for all clients and instances
|
||||
/// </summary>
|
||||
public class HealthStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Health status of download clients
|
||||
/// </summary>
|
||||
public List<DownloadClientHealthDto> DownloadClients { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Health status of arr instances
|
||||
/// </summary>
|
||||
public List<ArrInstanceHealthDto> ArrInstances { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health status DTO for a download client
|
||||
/// </summary>
|
||||
public class DownloadClientHealthDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public bool IsHealthy { get; set; }
|
||||
public DateTime LastChecked { get; set; }
|
||||
public double? ResponseTimeMs { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health status DTO for an arr instance
|
||||
/// </summary>
|
||||
public class ArrInstanceHealthDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public bool IsHealthy { get; set; }
|
||||
public DateTime LastChecked { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recent event DTO for stats endpoint
|
||||
/// </summary>
|
||||
public class RecentEventDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
public string? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recent strike DTO for stats endpoint
|
||||
/// </summary>
|
||||
public class RecentStrikeDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Service for aggregating application statistics
|
||||
/// </summary>
|
||||
public class StatsService : IStatsService
|
||||
{
|
||||
private readonly ILogger<StatsService> _logger;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public StatsService(
|
||||
ILogger<StatsService> logger,
|
||||
EventsContext eventsContext,
|
||||
IHealthCheckService healthCheckService,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_eventsContext = eventsContext;
|
||||
_healthCheckService = healthCheckService;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StatsResponse> GetStatsAsync(int hours = 24, int includeEvents = 0, int includeStrikes = 0)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddHours(-hours);
|
||||
|
||||
var eventStats = await GetEventStatsAsync(cutoff, hours, includeEvents);
|
||||
var strikeStats = await GetStrikeStatsAsync(cutoff, hours, includeStrikes);
|
||||
var jobStats = await GetJobStatsAsync(cutoff, hours);
|
||||
var healthStats = GetHealthStats();
|
||||
|
||||
return new StatsResponse
|
||||
{
|
||||
Events = eventStats,
|
||||
Strikes = strikeStats,
|
||||
Jobs = jobStats,
|
||||
Health = healthStats,
|
||||
GeneratedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EventStats> GetEventStatsAsync(DateTime cutoff, int hours, int includeEvents)
|
||||
{
|
||||
var eventsByType = await _eventsContext.Events
|
||||
.Where(e => e.Timestamp >= cutoff)
|
||||
.GroupBy(e => e.EventType)
|
||||
.Select(g => new { Type = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var eventsBySeverity = await _eventsContext.Events
|
||||
.Where(e => e.Timestamp >= cutoff)
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var stats = new EventStats
|
||||
{
|
||||
TotalCount = eventsByType.Sum(e => e.Count),
|
||||
ByType = eventsByType.ToDictionary(e => e.Type.ToString(), e => e.Count),
|
||||
BySeverity = eventsBySeverity.ToDictionary(e => e.Severity.ToString(), e => e.Count),
|
||||
TimeframeHours = hours
|
||||
};
|
||||
|
||||
if (includeEvents > 0)
|
||||
{
|
||||
stats.RecentItems = await _eventsContext.Events
|
||||
.Where(e => e.Timestamp >= cutoff)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(includeEvents)
|
||||
.Select(e => new RecentEventDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
EventType = e.EventType.ToString(),
|
||||
Message = e.Message,
|
||||
Severity = e.Severity.ToString(),
|
||||
Data = e.Data
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async Task<StrikeStats> GetStrikeStatsAsync(DateTime cutoff, int hours, int includeStrikes)
|
||||
{
|
||||
var strikesByType = await _eventsContext.Strikes
|
||||
.Where(s => s.CreatedAt >= cutoff)
|
||||
.GroupBy(s => s.Type)
|
||||
.Select(g => new { Type = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var itemsRemoved = await _eventsContext.DownloadItems
|
||||
.Where(d => d.IsRemoved && d.Strikes.Any(s => s.CreatedAt >= cutoff))
|
||||
.CountAsync();
|
||||
|
||||
var stats = new StrikeStats
|
||||
{
|
||||
TotalCount = strikesByType.Sum(s => s.Count),
|
||||
ByType = strikesByType.ToDictionary(s => s.Type.ToString(), s => s.Count),
|
||||
ItemsRemoved = itemsRemoved,
|
||||
TimeframeHours = hours
|
||||
};
|
||||
|
||||
if (includeStrikes > 0)
|
||||
{
|
||||
stats.RecentItems = await _eventsContext.Strikes
|
||||
.Include(s => s.DownloadItem)
|
||||
.Where(s => s.CreatedAt >= cutoff)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Take(includeStrikes)
|
||||
.Select(s => new RecentStrikeDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Type = s.Type.ToString(),
|
||||
CreatedAt = s.CreatedAt,
|
||||
DownloadId = s.DownloadItem.DownloadId,
|
||||
Title = s.DownloadItem.Title
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async Task<JobStats> GetJobStatsAsync(DateTime cutoff, int hours)
|
||||
{
|
||||
var jobRuns = await _eventsContext.JobRuns
|
||||
.Where(j => j.StartedAt >= cutoff)
|
||||
.GroupBy(j => j.Type)
|
||||
.Select(g => new
|
||||
{
|
||||
Type = g.Key,
|
||||
TotalRuns = g.Count(),
|
||||
Completed = g.Count(j => j.Status == JobRunStatus.Completed),
|
||||
Failed = g.Count(j => j.Status == JobRunStatus.Failed),
|
||||
LastRunAt = g.Max(j => j.StartedAt)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var byType = jobRuns.ToDictionary(
|
||||
j => j.Type.ToString(),
|
||||
j => new JobTypeStats
|
||||
{
|
||||
TotalRuns = j.TotalRuns,
|
||||
Completed = j.Completed,
|
||||
Failed = j.Failed,
|
||||
LastRunAt = j.LastRunAt
|
||||
});
|
||||
|
||||
var allJobs = await _jobManagementService.GetAllJobs();
|
||||
foreach (var job in allJobs)
|
||||
{
|
||||
if (byType.TryGetValue(job.JobType, out var stats))
|
||||
{
|
||||
stats.NextRunAt = job.NextRunTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
byType[job.JobType] = new JobTypeStats { NextRunAt = job.NextRunTime };
|
||||
}
|
||||
}
|
||||
|
||||
return new JobStats
|
||||
{
|
||||
ByType = byType,
|
||||
TimeframeHours = hours
|
||||
};
|
||||
}
|
||||
|
||||
private HealthStats GetHealthStats()
|
||||
{
|
||||
var downloadClientHealth = _healthCheckService.GetAllClientHealth();
|
||||
var arrHealth = _healthCheckService.GetAllArrInstanceHealth();
|
||||
|
||||
return new HealthStats
|
||||
{
|
||||
DownloadClients = downloadClientHealth.Values.Select(h => new DownloadClientHealthDto
|
||||
{
|
||||
Id = h.ClientId,
|
||||
Name = h.ClientName,
|
||||
Type = h.ClientTypeName.ToString(),
|
||||
IsHealthy = h.IsHealthy,
|
||||
LastChecked = h.LastChecked,
|
||||
ResponseTimeMs = h.ResponseTime.TotalMilliseconds,
|
||||
ErrorMessage = h.ErrorMessage
|
||||
}).ToList(),
|
||||
ArrInstances = arrHealth.Values.Select(h => new ArrInstanceHealthDto
|
||||
{
|
||||
Id = h.InstanceId,
|
||||
Name = h.InstanceName,
|
||||
Type = h.InstanceType.ToString(),
|
||||
IsHealthy = h.IsHealthy,
|
||||
LastChecked = h.LastChecked,
|
||||
ErrorMessage = h.ErrorMessage
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
+73
-1
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -103,7 +104,78 @@ public sealed class DownloadCleanerConfigTests
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Duplicated clean categories found");
|
||||
exception.Message.ShouldBe("Duplicated clean category and privacy type combination found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenDuplicateCategoryNamesDifferentCase_ThrowsValidationException()
|
||||
{
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Enabled = true,
|
||||
Categories =
|
||||
[
|
||||
new SeedingRule { Name = "Movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "movies", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
],
|
||||
UnlinkedEnabled = false
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Duplicated clean category and privacy type combination found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenSameCategoryWithBothAndPublic_ThrowsValidationException()
|
||||
{
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Enabled = true,
|
||||
Categories =
|
||||
[
|
||||
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Both, MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Public, MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
],
|
||||
UnlinkedEnabled = false
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("already covers all torrent types");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenSameCategoryWithBothAndPrivate_ThrowsValidationException()
|
||||
{
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Enabled = true,
|
||||
Categories =
|
||||
[
|
||||
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Both, MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Private, MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
],
|
||||
UnlinkedEnabled = false
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("already covers all torrent types");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenSameCategoryWithPublicAndPrivate_DoesNotThrow()
|
||||
{
|
||||
var config = new DownloadCleanerConfig
|
||||
{
|
||||
Enabled = true,
|
||||
Categories =
|
||||
[
|
||||
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Public, MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
|
||||
new SeedingRule { Name = "movies", PrivacyType = TorrentPrivacyType.Private, MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
|
||||
],
|
||||
UnlinkedEnabled = false
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+20
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -7,6 +8,25 @@ namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
|
||||
|
||||
public sealed class SeedingRuleTests
|
||||
{
|
||||
#region Default Values
|
||||
|
||||
[Fact]
|
||||
public void PrivacyType_DefaultsToPublic()
|
||||
{
|
||||
var rule = new SeedingRule
|
||||
{
|
||||
Name = "test",
|
||||
MaxRatio = -1,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = 24,
|
||||
DeleteSourceFiles = false
|
||||
};
|
||||
|
||||
rule.PrivacyType.ShouldBe(TorrentPrivacyType.Public);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Valid Configurations
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -88,11 +88,20 @@ public class DataContext : DbContext
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<GeneralConfig>(entity =>
|
||||
{
|
||||
entity.ComplexProperty(e => e.Log, cp =>
|
||||
{
|
||||
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
entity.ComplexProperty(e => e.Auth, cp =>
|
||||
{
|
||||
cp.Property(a => a.TrustedNetworks)
|
||||
.HasConversion(
|
||||
v => string.Join(',', v),
|
||||
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity<QueueCleanerConfig>(entity =>
|
||||
{
|
||||
|
||||
+1286
File diff suppressed because it is too large.
Load diff
+50
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeedingRulePrivacyType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "privacy_type",
|
||||
table: "seeding_rules",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "both");
|
||||
|
||||
// Migrate existing data: if DeletePrivate was true, rules apply to "both";
|
||||
// if false, rules only apply to "public" (preserving existing behavior)
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE seeding_rules
|
||||
SET privacy_type = CASE
|
||||
WHEN (SELECT delete_private FROM download_cleaner_configs LIMIT 1) = 1 THEN 'both'
|
||||
ELSE 'public'
|
||||
END
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "delete_private",
|
||||
table: "download_cleaner_configs");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "privacy_type",
|
||||
table: "seeding_rules");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "delete_private",
|
||||
table: "download_cleaner_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1304
File diff suppressed because it is too large.
Load diff
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user