mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-10 12:31:24 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df986a2e36 | ||
|
|
8a2aca79f7 | ||
|
|
e1cc0dba28 | ||
|
|
660dce0fa7 | ||
|
|
ee343ba469 | ||
|
|
d038738008 | ||
|
|
70f9995041 | ||
|
|
6a1aaec7c2 | ||
|
|
50e486bf5f | ||
|
|
bc15a9a934 | ||
|
|
573dbcf882 |
No files matched your search
@@ -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
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "auth_disable_auth_for_local_addresses",
|
||||
table: "general_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "auth_trust_forwarded_headers",
|
||||
table: "general_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "auth_trusted_networks",
|
||||
table: "general_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "auth_disable_auth_for_local_addresses",
|
||||
table: "general_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "auth_trust_forwarded_headers",
|
||||
table: "general_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "auth_trusted_networks",
|
||||
table: "general_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user