Compare commits

..

8 Commits

Author SHA1 Message Date
Flaminel
6b94e05092 fixed some comments 2026-02-16 19:07:50 +02:00
Flaminel
d4ac8c8ddf added some tests 2026-02-16 18:14:40 +02:00
Flaminel
9c6560b159 fixed context variables 2026-02-16 17:49:29 +02:00
Flaminel
8fdc49f65a decreased number of concurrent deletes 2026-02-16 17:49:08 +02:00
Flaminel
f906e6ed14 fixed frontend inputs 2026-02-16 16:46:58 +02:00
Flaminel
69b50499b5 fixed files call 2026-02-16 16:46:49 +02:00
Flaminel
cc735bd4e2 fixed with main 2026-02-15 18:03:25 +02:00
Flaminel
76767adb1f added rTorrent support 2026-02-15 17:44:53 +02:00
111 changed files with 3689 additions and 6032 deletions

View File

@@ -15,12 +15,6 @@ 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=...)

View File

@@ -1,27 +0,0 @@
<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>

View File

@@ -1,61 +0,0 @@
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 */ }
}
}
}

View File

@@ -1,232 +0,0 @@
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
}

View File

@@ -1,37 +0,0 @@
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;
}
}
}
}

View File

@@ -1,12 +0,0 @@
namespace Cleanuparr.Api.Tests;
[AttributeUsage(AttributeTargets.Method)]
public sealed class TestPriorityAttribute : Attribute
{
public int Priority { get; }
public TestPriorityAttribute(int priority)
{
Priority = priority;
}
}

View File

@@ -1,69 +0,0 @@
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);
}
}

View File

@@ -24,7 +24,6 @@
<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>

View File

@@ -65,13 +65,9 @@ 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();
@@ -112,11 +108,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").RequireAuthorization();
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
app.MapHub<HealthStatusHub>("/api/hubs/health");
app.MapHub<AppHub>("/api/hubs/app");
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
{
@@ -148,7 +144,7 @@ public static class ApiDI
};
return Results.Json(manifest, contentType: "application/manifest+json");
}).AllowAnonymous();
});
return app;
}

View File

@@ -1,81 +0,0 @@
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;
}
return JwtBearerDefaults.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, _ => { });
services.AddAuthorization(options =>
{
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.DefaultPolicy = defaultPolicy;
options.FallbackPolicy = defaultPolicy;
});
return services;
}
}

View File

@@ -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,13 +87,10 @@ 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;
}

View File

@@ -2,7 +2,6 @@ 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;
@@ -27,11 +26,6 @@ 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>()

View File

@@ -1,14 +0,0 @@
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; }
}

View File

@@ -1,16 +0,0 @@
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; }
}

View File

@@ -1,12 +0,0 @@
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; }
}

View File

@@ -1,9 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record PlexPinRequest
{
[Required]
public required int PinId { get; init; }
}

View File

@@ -1,9 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record RefreshTokenRequest
{
[Required]
public required string RefreshToken { get; init; }
}

View File

@@ -1,13 +0,0 @@
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; }
}

View File

@@ -1,14 +0,0 @@
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; }
}

View File

@@ -1,10 +0,0 @@
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; }
}

View File

@@ -1,10 +0,0 @@
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; }
}

View File

@@ -1,7 +0,0 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record AuthStatusResponse
{
public required bool SetupCompleted { get; init; }
public bool PlexLinked { get; init; }
}

View File

@@ -1,7 +0,0 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record LoginResponse
{
public required bool RequiresTwoFactor { get; init; }
public string? LoginToken { get; init; }
}

View File

@@ -1,13 +0,0 @@
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; }
}

View File

@@ -1,8 +0,0 @@
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; }
}

View File

@@ -1,8 +0,0 @@
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; }
}

View File

@@ -1,265 +0,0 @@
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();
}
}
[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);
}
}

View File

@@ -1,561 +0,0 @@
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/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();
return Ok(new AuthStatusResponse
{
SetupCompleted = user is { SetupCompleted: true },
PlexLinked = user?.PlexAccountId is not null
});
}
[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" });
}
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA must be configured before completing setup" });
}
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);
// 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);
}
}

View File

@@ -63,14 +63,7 @@ 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;
}
}

View File

@@ -1,66 +0,0 @@
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/");
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -71,19 +70,12 @@ builder.Services.ConfigureHttpJsonOptions(options =>
// Add services to the container
builder.Services
.AddInfrastructure(builder.Configuration)
.AddApiServices()
.AddAuthServices();
// Persist Data Protection keys to the config directory
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
.SetApplicationName("Cleanuparr");
.AddApiServices();
// 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
@@ -154,14 +146,14 @@ app.Init();
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// Configure health check endpoints as middleware (before auth pipeline) so they don't require authentication
app.UseHealthChecks("/health", new HealthCheckOptions
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.UseHealthChecks("/health/ready", new HealthCheckOptions
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext

View File

@@ -27,6 +27,8 @@ public interface ITorrentItemWrapper
long SeedingTimeSeconds { get; }
string? Category { get; set; }
string SavePath { get; }
bool IsDownloading();

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -6,4 +6,5 @@ public enum DownloadClientTypeName
Deluge,
Transmission,
uTorrent,
rTorrent,
}

View File

@@ -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)
{
}
}

View File

@@ -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(

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
@@ -503,13 +504,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 +526,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(

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -7,16 +7,12 @@
</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" />

View File

@@ -1,14 +0,0 @@
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();
}

View File

@@ -1,7 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
public interface IPasswordService
{
string HashPassword(string password);
bool VerifyPassword(string password, string hash);
}

View File

@@ -1,28 +0,0 @@
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);
}

View File

@@ -1,11 +0,0 @@
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);
}

View File

@@ -1,138 +0,0 @@
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;
}
}
}

View File

@@ -1,23 +0,0 @@
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;
}
}
}

View File

@@ -1,158 +0,0 @@
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; }
}
}

View File

@@ -1,76 +0,0 @@
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;
}
}
}

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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();
@@ -123,7 +120,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 +150,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 +242,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;
}
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public interface IRTorrentService : IDownloadService
{
}

View File

@@ -0,0 +1,399 @@
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 response = await _httpClient.PostAsync(_config.Url, content);
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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,108 @@
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 doesn't have its own authentication - it relies on HTTP Basic Auth
/// handled by the reverse proxy (nginx/apache). No action needed here.
/// </summary>
public override Task LoginAsync()
{
_logger.LogDebug("rTorrent authentication is handled by HTTP Basic Auth via reverse proxy for client {clientId}", _downloadClientConfig.Id);
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()
{
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 &&

View File

@@ -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)
{

View File

@@ -111,7 +111,6 @@ 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");

View File

@@ -1,215 +0,0 @@
// <auto-generated />
using System;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Users
{
[DbContext(typeof(UsersContext))]
[Migration("20260215094545_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CodeHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("code_hash");
b.Property<bool>("IsUsed")
.HasColumnType("INTEGER")
.HasColumnName("is_used");
b.Property<DateTime?>("UsedAt")
.HasColumnType("TEXT")
.HasColumnName("used_at");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_recovery_codes");
b.HasIndex("UserId")
.HasDatabaseName("ix_recovery_codes_user_id");
b.ToTable("recovery_codes", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires_at");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked_at");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("token_hash");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_refresh_tokens");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_refresh_tokens_token_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_refresh_tokens_user_id");
b.ToTable("refresh_tokens", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("FailedLoginAttempts")
.HasColumnType("INTEGER")
.HasColumnName("failed_login_attempts");
b.Property<DateTime?>("LockoutEnd")
.HasColumnType("TEXT")
.HasColumnName("lockout_end");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("password_hash");
b.Property<string>("PlexAccountId")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("plex_account_id");
b.Property<string>("PlexAuthToken")
.HasColumnType("TEXT")
.HasColumnName("plex_auth_token");
b.Property<string>("PlexEmail")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("plex_email");
b.Property<string>("PlexUsername")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("plex_username");
b.Property<bool>("SetupCompleted")
.HasColumnType("INTEGER")
.HasColumnName("setup_completed");
b.Property<bool>("TotpEnabled")
.HasColumnType("INTEGER")
.HasColumnName("totp_enabled");
b.Property<string>("TotpSecret")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("totp_secret");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("ApiKey")
.IsUnique()
.HasDatabaseName("ix_users_api_key");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
.WithMany("RecoveryCodes")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_recovery_codes_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_refresh_tokens_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
{
b.Navigation("RecoveryCodes");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,124 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Users
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
username = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
password_hash = table.Column<string>(type: "TEXT", nullable: false),
totp_secret = table.Column<string>(type: "TEXT", nullable: false),
totp_enabled = table.Column<bool>(type: "INTEGER", nullable: false),
plex_account_id = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
plex_username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
plex_email = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
plex_auth_token = table.Column<string>(type: "TEXT", nullable: true),
api_key = table.Column<string>(type: "TEXT", nullable: false),
setup_completed = table.Column<bool>(type: "INTEGER", nullable: false),
failed_login_attempts = table.Column<int>(type: "INTEGER", nullable: false),
lockout_end = table.Column<DateTime>(type: "TEXT", nullable: true),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
updated_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_users", x => x.id);
});
migrationBuilder.CreateTable(
name: "recovery_codes",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
code_hash = table.Column<string>(type: "TEXT", nullable: false),
is_used = table.Column<bool>(type: "INTEGER", nullable: false),
used_at = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_recovery_codes", x => x.id);
table.ForeignKey(
name: "fk_recovery_codes_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "refresh_tokens",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
token_hash = table.Column<string>(type: "TEXT", nullable: false),
expires_at = table.Column<DateTime>(type: "TEXT", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
revoked_at = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_refresh_tokens", x => x.id);
table.ForeignKey(
name: "fk_refresh_tokens_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_recovery_codes_user_id",
table: "recovery_codes",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_token_hash",
table: "refresh_tokens",
column: "token_hash",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_user_id",
table: "refresh_tokens",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_users_api_key",
table: "users",
column: "api_key",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_users_username",
table: "users",
column: "username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "recovery_codes");
migrationBuilder.DropTable(
name: "refresh_tokens");
migrationBuilder.DropTable(
name: "users");
}
}
}

View File

@@ -1,212 +0,0 @@
// <auto-generated />
using System;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Users
{
[DbContext(typeof(UsersContext))]
partial class UsersContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CodeHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("code_hash");
b.Property<bool>("IsUsed")
.HasColumnType("INTEGER")
.HasColumnName("is_used");
b.Property<DateTime?>("UsedAt")
.HasColumnType("TEXT")
.HasColumnName("used_at");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_recovery_codes");
b.HasIndex("UserId")
.HasDatabaseName("ix_recovery_codes_user_id");
b.ToTable("recovery_codes", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires_at");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked_at");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("token_hash");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_refresh_tokens");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_refresh_tokens_token_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_refresh_tokens_user_id");
b.ToTable("refresh_tokens", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("FailedLoginAttempts")
.HasColumnType("INTEGER")
.HasColumnName("failed_login_attempts");
b.Property<DateTime?>("LockoutEnd")
.HasColumnType("TEXT")
.HasColumnName("lockout_end");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("password_hash");
b.Property<string>("PlexAccountId")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("plex_account_id");
b.Property<string>("PlexAuthToken")
.HasColumnType("TEXT")
.HasColumnName("plex_auth_token");
b.Property<string>("PlexEmail")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("plex_email");
b.Property<string>("PlexUsername")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("plex_username");
b.Property<bool>("SetupCompleted")
.HasColumnType("INTEGER")
.HasColumnName("setup_completed");
b.Property<bool>("TotpEnabled")
.HasColumnType("INTEGER")
.HasColumnName("totp_enabled");
b.Property<string>("TotpSecret")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("totp_secret");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("ApiKey")
.IsUnique()
.HasDatabaseName("ix_users_api_key");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
.WithMany("RecoveryCodes")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_recovery_codes_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_refresh_tokens_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
{
b.Navigation("RecoveryCodes");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Shared.Attributes;
namespace Cleanuparr.Persistence.Models.Auth;
public class RecoveryCode
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
[Required]
[SensitiveData]
public required string CodeHash { get; set; }
public bool IsUsed { get; set; }
public DateTime? UsedAt { get; set; }
public User User { get; set; } = null!;
}

View File

@@ -1,23 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Shared.Attributes;
namespace Cleanuparr.Persistence.Models.Auth;
public class RefreshToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
[Required]
[SensitiveData]
public required string TokenHash { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? RevokedAt { get; set; }
public User User { get; set; } = null!;
}

View File

@@ -1,53 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Shared.Attributes;
namespace Cleanuparr.Persistence.Models.Auth;
public class User
{
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public required string Username { get; set; }
[Required]
[SensitiveData]
public required string PasswordHash { get; set; }
[Required]
[SensitiveData]
public required string TotpSecret { get; set; }
public bool TotpEnabled { get; set; }
[MaxLength(100)]
public string? PlexAccountId { get; set; }
[MaxLength(100)]
public string? PlexUsername { get; set; }
[MaxLength(200)]
public string? PlexEmail { get; set; }
[SensitiveData]
public string? PlexAuthToken { get; set; }
[Required]
[SensitiveData]
public required string ApiKey { get; set; }
public bool SetupCompleted { get; set; }
public int FailedLoginAttempts { get; set; }
public DateTime? LockoutEnd { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public List<RecoveryCode> RecoveryCodes { get; set; } = [];
public List<RefreshToken> RefreshTokens { get; set; } = [];
}

View File

@@ -1,102 +0,0 @@
using Cleanuparr.Persistence.Converters;
using Cleanuparr.Persistence.Models.Auth;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Persistence;
/// <summary>
/// Database context for user authentication data
/// </summary>
public class UsersContext : DbContext
{
public static SemaphoreSlim Lock { get; } = new(1, 1);
public DbSet<User> Users { get; set; }
public DbSet<RecoveryCode> RecoveryCodes { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; }
public UsersContext()
{
}
public UsersContext(DbContextOptions<UsersContext> options) : base(options)
{
}
public static UsersContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<UsersContext>();
SetDbContextOptions(optionsBuilder);
return new UsersContext(optionsBuilder.Options);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
SetDbContextOptions(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>(entity =>
{
entity.HasIndex(u => u.Username).IsUnique();
entity.HasIndex(u => u.ApiKey).IsUnique();
entity.Property(u => u.CreatedAt)
.HasConversion(new UtcDateTimeConverter());
entity.Property(u => u.UpdatedAt)
.HasConversion(new UtcDateTimeConverter());
entity.Property(u => u.LockoutEnd)
.HasConversion(new UtcDateTimeConverter());
entity.HasMany(u => u.RecoveryCodes)
.WithOne(r => r.User)
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(u => u.RefreshTokens)
.WithOne(r => r.User)
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RecoveryCode>(entity =>
{
entity.Property(r => r.UsedAt)
.HasConversion(new UtcDateTimeConverter());
});
modelBuilder.Entity<RefreshToken>(entity =>
{
entity.HasIndex(r => r.TokenHash).IsUnique();
entity.Property(r => r.ExpiresAt)
.HasConversion(new UtcDateTimeConverter());
entity.Property(r => r.CreatedAt)
.HasConversion(new UtcDateTimeConverter());
entity.Property(r => r.RevokedAt)
.HasConversion(new UtcDateTimeConverter());
});
}
private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "users.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
}
}

View File

@@ -14,8 +14,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Infrastructure.T
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Persistence.Tests", "Cleanuparr.Persistence.Tests\Cleanuparr.Persistence.Tests.csproj", "{7037FF30-4890-4435-B4A9-04A7A48188CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Api.Tests", "Cleanuparr.Api.Tests\Cleanuparr.Api.Tests.csproj", "{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -50,9 +48,5 @@ Global
{7037FF30-4890-4435-B4A9-04A7A48188CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7037FF30-4890-4435-B4A9-04A7A48188CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7037FF30-4890-4435-B4A9-04A7A48188CE}.Release|Any CPU.Build.0 = Release|Any CPU
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8D4A0C27-5D8F-44B6-967C-85E6CEE6BE7F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -21,7 +21,6 @@
"@ng-icons/tabler-icons": "^33.0.0",
"@ngrx/signals": "^21.0.1",
"@tailwindcss/postcss": "^4.1.18",
"angularx-qrcode": "^21.0.4",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
"tailwindcss": "^4.1.18",
@@ -279,13 +278,13 @@
}
},
"node_modules/@angular-devkit/architect": {
"version": "0.2101.4",
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.4.tgz",
"integrity": "sha512-3yyebORk+ovtO+LfDjIGbGCZhCMDAsyn9vkCljARj3sSshS4blOQBar0g+V3kYAweLT5Gf+rTKbN5jneOkBAFQ==",
"version": "0.2101.3",
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.3.tgz",
"integrity": "sha512-vKz8aPA62W+e9+pF6ct4CRDG/MjlIH7sWFGYkxPPRst2g46ZQsRkrzfMZAWv/wnt6OZ1OwyRuO3RW83EMhag8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": "21.1.4",
"@angular-devkit/core": "21.1.3",
"rxjs": "7.8.2"
},
"bin": {
@@ -298,9 +297,9 @@
}
},
"node_modules/@angular-devkit/core": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.4.tgz",
"integrity": "sha512-ObPTI5gYCB1jGxTRhcqZ6oQVUBFVJ8GH4LksVuAiz0nFX7xxpzARWvlhq943EtnlovVlUd9I8fM3RQqjfGVVAQ==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.3.tgz",
"integrity": "sha512-huEXd1tWQHwwN+0VGRT+vSVplV0KNrGFUGJzkIW6iJE1SQElxn6etMai+pSd5DJcePkx6+SuscVsxbfwf70hnA==",
"license": "MIT",
"dependencies": {
"ajv": "8.17.1",
@@ -325,13 +324,13 @@
}
},
"node_modules/@angular-devkit/schematics": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.4.tgz",
"integrity": "sha512-Nqq0ioCUxrbEX+L4KOarETcZZJNnJ1mAJ0ubO4VM91qnn8RBBM9SnQ91590TfC34Szk/wh+3+Uj6KUvTJNuegQ==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.3.tgz",
"integrity": "sha512-Ps7bRl5uOcM7WpNJHbSls/jz5/wAI0ldkTlKyiBFA7RtNeQIABAV+hvlw5DJuEb1Lo5hnK0hXj90AyZdOxzY+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/core": "21.1.4",
"@angular-devkit/core": "21.1.3",
"jsonc-parser": "3.3.1",
"magic-string": "0.30.21",
"ora": "9.0.0",
@@ -435,9 +434,9 @@
}
},
"node_modules/@angular/animations": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.4.tgz",
"integrity": "sha512-8xQ0Ylw7qqVyw4ZJ/Tyw/z5Mtqtp8AMj+R+Z1sCWcyxBgDU4+qfxteVYdiipWC3tX77A0FTsXqwvNP9WVY2/WA==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.3.tgz",
"integrity": "sha512-UADMncDd9lkmIT1NPVFcufyP5gJHMPzxNaQpojiGrxT1aT8Du30mao0KSrB4aTwcicv6/cdD5bZbIyg+FL6LkQ==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -447,18 +446,18 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.1.4"
"@angular/core": "21.1.3"
}
},
"node_modules/@angular/build": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.4.tgz",
"integrity": "sha512-7CAAQPWFMMqod40ox5MOVB/CnoBXFDehyQhs0hls6lu7bOy/M0EDy0v6bERkyNGRz1mihWWBiCV8XzEinrlq1A==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.3.tgz",
"integrity": "sha512-RXVRuamfrSPwsFCLJgsO2ucp+dwWDbGbhXrQnMrGXm0qdgYpI8bAsBMd8wOeUA6vn4fRmjaRFQZbL/rcIVrkzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "2.3.0",
"@angular-devkit/architect": "0.2101.4",
"@angular-devkit/architect": "0.2101.3",
"@babel/core": "7.28.5",
"@babel/helper-annotate-as-pure": "7.27.3",
"@babel/helper-split-export-declaration": "7.24.7",
@@ -501,7 +500,7 @@
"@angular/platform-browser": "^21.0.0",
"@angular/platform-server": "^21.0.0",
"@angular/service-worker": "^21.0.0",
"@angular/ssr": "^21.1.4",
"@angular/ssr": "^21.1.3",
"karma": "^6.4.0",
"less": "^4.2.0",
"ng-packagr": "^21.0.0",
@@ -551,9 +550,9 @@
}
},
"node_modules/@angular/cdk": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.4.tgz",
"integrity": "sha512-PElA4Ww4TIa3+B/ND+fm8ZPDKONTIqc9a/s0qNxhcAD9IpDqjaBVi/fyg+ZWBtS+x0DQgJtKeCsSZ6sr2aFQaQ==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.3.tgz",
"integrity": "sha512-jMiEKCcZMIAnyx2jxrJHmw5c7JXAiN56ErZ4X+OuQ5yFvYRocRVEs25I0OMxntcXNdPTJQvpGwGlhWhS0yDorg==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
@@ -567,20 +566,20 @@
}
},
"node_modules/@angular/cli": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.4.tgz",
"integrity": "sha512-XsMHgxTvHGiXXrhYZz3zMZYhYU0gHdpoHKGiEKXwcx+S1KoYbIssyg6oF2Kq49ZaE0OYCTKjnvgDce6ZqdkJ/A==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.3.tgz",
"integrity": "sha512-UPtDcpKyrKZRPfym9gTovcibPzl2O/Woy7B8sm45sAnjDH+jDUCcCvuIak7GpH47shQkC2J4yvnHZbD4c6XxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/architect": "0.2101.4",
"@angular-devkit/core": "21.1.4",
"@angular-devkit/schematics": "21.1.4",
"@angular-devkit/architect": "0.2101.3",
"@angular-devkit/core": "21.1.3",
"@angular-devkit/schematics": "21.1.3",
"@inquirer/prompts": "7.10.1",
"@listr2/prompt-adapter-inquirer": "3.0.5",
"@modelcontextprotocol/sdk": "1.26.0",
"@schematics/angular": "21.1.4",
"@schematics/angular": "21.1.3",
"@yarnpkg/lockfile": "1.1.0",
"algoliasearch": "5.46.2",
"ini": "6.0.0",
@@ -604,9 +603,9 @@
}
},
"node_modules/@angular/common": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.1.4.tgz",
"integrity": "sha512-1uOxPrHO9PFZBU/JavzYzjxAm+5x7vD2z6AeUndqdT4LjqOBIePswxFDRqM9dlfF8FIwnnfmNFipiC/yQjJSnA==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.1.3.tgz",
"integrity": "sha512-Wdbln/UqZM5oVnpfIydRdhhL8A9x3bKZ9Zy1/mM0q+qFSftPvmFZIXhEpFqbDwNYbGUhGzx7t8iULC4sVVp/zA==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -616,14 +615,14 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.1.4",
"@angular/core": "21.1.3",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.1.4.tgz",
"integrity": "sha512-H0qtASeqOTaS44ioF4DYE/yNqwzUmEJpMYrcNEUFEwA20/DkLzyoaEx4y1CjIxtXxuhtge95PNymDBOLWSjIdg==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.1.3.tgz",
"integrity": "sha512-gDNLh7MEf7Qf88ktZzS4LJQXCA5U8aQTfK9ak+0mi2ruZ0x4XSjQCro4H6OPKrrbq94+6GcnlSX5+oVIajEY3w==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -634,9 +633,9 @@
}
},
"node_modules/@angular/compiler-cli": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.1.4.tgz",
"integrity": "sha512-Uw8KmpVCo58/f5wf6pY8ZS5fodv65hn5jxms8Nv/K7/LVe3i1nNFrHyneBx5+a7qkz93nSV4rdwBVnMvjIyr+g==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.1.3.tgz",
"integrity": "sha512-nKxoQ89W2B1WdonNQ9kgRnvLNS6DAxDrRHBslsKTlV+kbdv7h59M9PjT4ZZ2sp1M/M8LiofnUfa/s2jd/xYj5w==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -658,7 +657,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/compiler": "21.1.4",
"@angular/compiler": "21.1.3",
"typescript": ">=5.9 <6.0"
},
"peerDependenciesMeta": {
@@ -668,9 +667,9 @@
}
},
"node_modules/@angular/core": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.1.4.tgz",
"integrity": "sha512-QBDO5SaVYTVQ0fIN9Qd7US9cUCgs2vM9x6K18PTUKmygIkHVHTFdzwm4MO5gpCOFzJseGbS+dNzqn+v0PaKf9g==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.1.3.tgz",
"integrity": "sha512-TbhQxRC7Lb/3WBdm1n8KRsktmVEuGBBp0WRF5mq0Ze4s1YewIM6cULrSw9ACtcL5jdcq7c74ms+uKQsaP/gdcQ==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -680,7 +679,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/compiler": "21.1.4",
"@angular/compiler": "21.1.3",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
},
@@ -694,9 +693,9 @@
}
},
"node_modules/@angular/forms": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.1.4.tgz",
"integrity": "sha512-duVT/eOncmFNBYRlK/F7WDg6GD1vL1mxUrDdnp7M9J8JvNrKH0PvdfzuOAmjbB8/bsvUNTLFXCV4+43Mc2Hqsw==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.1.3.tgz",
"integrity": "sha512-YW/YdjM9suZUeJam9agHFXIEE3qQIhGYXMjnnX7xGjOe+CuR2R0qsWn1AR0yrKrNmFspb0lKgM7kTTJyzt8gZg==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -706,16 +705,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/common": "21.1.4",
"@angular/core": "21.1.4",
"@angular/platform-browser": "21.1.4",
"@angular/common": "21.1.3",
"@angular/core": "21.1.3",
"@angular/platform-browser": "21.1.3",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.4.tgz",
"integrity": "sha512-S6Iw5CkORih5omh+MQY35w0bUBxdSFAPLDg386S6/9fEUjDClo61hvXNKxaNh9g7tnh1LD7zmTmKrqufnhnFDQ==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.3.tgz",
"integrity": "sha512-W+ZMXAioaP7CsACafBCHsIxiiKrRTPOlQ+hcC7XNBwy+bn5mjGONoCgLreQs76M8HNWLtr/OAUAr6h26OguOuA==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -725,9 +724,9 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/animations": "21.1.4",
"@angular/common": "21.1.4",
"@angular/core": "21.1.4"
"@angular/animations": "21.1.3",
"@angular/common": "21.1.3",
"@angular/core": "21.1.3"
},
"peerDependenciesMeta": {
"@angular/animations": {
@@ -736,9 +735,9 @@
}
},
"node_modules/@angular/router": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.1.4.tgz",
"integrity": "sha512-nPYuRJ8ub/X8GK55U2KqYy/ducVRn6HSoDmZz0yiXtI6haFsZlv9R1j5zi0EDIqrrN0HGARMs6jNDXZC1Ded3w==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.1.3.tgz",
"integrity": "sha512-uAw4LAMHXAPCe4SywhlUEWjMYVbbLHwTxLyduSp1b+9aVwep0juy5O/Xttlxd/oigVe0NMnOyJG9y1Br/ubnrg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -747,9 +746,9 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/common": "21.1.4",
"@angular/core": "21.1.4",
"@angular/platform-browser": "21.1.4",
"@angular/common": "21.1.3",
"@angular/core": "21.1.3",
"@angular/platform-browser": "21.1.3",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
@@ -2148,14 +2147,27 @@
}
}
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "MIT",
"engines": {
"node": ">=18"
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/fs-minipass": {
@@ -4016,14 +4028,14 @@
]
},
"node_modules/@schematics/angular": {
"version": "21.1.4",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.4.tgz",
"integrity": "sha512-I1zdSNzdbrVCWpeE2NsZQmIoa9m0nlw4INgdGIkqUH6FgwvoGKC0RoOxKAmm6HHVJ48FE/sPI13dwAeK89ow5A==",
"version": "21.1.3",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.3.tgz",
"integrity": "sha512-obJvWBhzRdsYL2msM4+8bQD21vFl3VxaVsuiq6iIfYsxhU5i2Iar2wM9NaRaIIqAYhZ8ehQQ/moB9BEbWvDCTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/core": "21.1.4",
"@angular-devkit/schematics": "21.1.4",
"@angular-devkit/core": "21.1.3",
"@angular-devkit/schematics": "21.1.3",
"jsonc-parser": "3.3.1"
},
"engines": {
@@ -4398,40 +4410,14 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@tufjs/models/node_modules/balanced-match": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jackspeak": "^4.2.3"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@tufjs/models/node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@tufjs/models/node_modules/minimatch": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
@@ -4856,20 +4842,6 @@
"node": ">= 14.0.0"
}
},
"node_modules/angularx-qrcode": {
"version": "21.0.4",
"resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-21.0.4.tgz",
"integrity": "sha512-GZFa/X/3rHx/4peA4zNROkK6UaYqxJX0dgkEMk7dCcoYNwJM8/UkOkEUfcx+Btjr7iT4UEhf9twWhFjFp58wfw==",
"license": "MIT",
"dependencies": {
"qrcode": "1.5.4",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -4902,6 +4874,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -5145,19 +5118,10 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
"dev": true,
"funding": [
{
@@ -5348,6 +5312,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -5360,6 +5325,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -5508,15 +5474,6 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5543,12 +5500,6 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -6438,6 +6389,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -6495,13 +6447,13 @@
}
},
"node_modules/glob": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.3.tgz",
"integrity": "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==",
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz",
"integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.0",
"minimatch": "^10.1.2",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
@@ -6532,40 +6484,14 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jackspeak": "^4.2.3"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
@@ -6805,40 +6731,14 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/ignore-walk/node_modules/balanced-match": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jackspeak": "^4.2.3"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ignore-walk/node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ignore-walk/node_modules/minimatch": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
@@ -7038,22 +6938,6 @@
"node": ">=10"
}
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -8407,15 +8291,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pacote": {
"version": "21.0.4",
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz",
@@ -8540,6 +8415,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8641,15 +8517,6 @@
"node": ">=16.20.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -8770,181 +8637,10 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -9010,15 +8706,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -9028,12 +8715,6 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -9326,12 +9007,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -10164,12 +9839,6 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -10184,6 +9853,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -10198,6 +9868,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10207,12 +9878,14 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10222,6 +9895,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -10236,6 +9910,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"

View File

@@ -35,7 +35,6 @@
"@ng-icons/tabler-icons": "^33.0.0",
"@ngrx/signals": "^21.0.1",
"@tailwindcss/postcss": "^4.1.18",
"angularx-qrcode": "^21.0.4",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
"tailwindcss": "^4.1.18",

View File

@@ -43,8 +43,6 @@ import {
tablerChevronUp,
tablerCopy,
tablerFileExport,
tablerUser,
tablerLogout,
} from '@ng-icons/tabler-icons';
import { routes } from './app.routes';
@@ -98,8 +96,6 @@ export const appConfig: ApplicationConfig = {
tablerChevronUp,
tablerCopy,
tablerFileExport,
tablerUser,
tablerLogout,
}),
],
};

View File

@@ -1,7 +1,7 @@
import { Routes } from '@angular/router';
import { ShellComponent } from '@layout/shell/shell.component';
import { AuthLayoutComponent } from '@layout/auth-layout/auth-layout.component';
import { authGuard, setupIncompleteGuard, loginGuard } from '@core/auth/auth.guard';
import { authGuard } from '@core/auth/auth.guard';
import { pendingChangesGuard } from '@core/guards/pending-changes.guard';
export const routes: Routes = [
@@ -104,13 +104,6 @@ export const routes: Routes = [
).then((m) => m.NotificationsComponent),
canDeactivate: [pendingChangesGuard],
},
{
path: 'account',
loadComponent: () =>
import(
'@features/settings/account/account-settings.component'
).then((m) => m.AccountSettingsComponent),
},
],
},
],
@@ -121,20 +114,11 @@ export const routes: Routes = [
children: [
{
path: 'login',
canActivate: [loginGuard],
loadComponent: () =>
import('@features/auth/login/login.component').then(
(m) => m.LoginComponent,
),
},
{
path: 'setup',
canActivate: [setupIncompleteGuard],
loadComponent: () =>
import('@features/auth/setup/setup.component').then(
(m) => m.SetupComponent,
),
},
],
},
{ path: '**', redirectTo: 'dashboard' },

View File

@@ -1,7 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from '@core/services/theme.service';
import { AuthService } from '@core/auth/auth.service';
import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
@Component({
@@ -14,12 +13,7 @@ import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
<app-confirm-dialog />
`,
})
export class App implements OnInit {
export class App {
// Inject ThemeService eagerly so it binds theme to DOM on startup
private themeService = inject(ThemeService);
private auth = inject(AuthService);
ngOnInit(): void {
this.auth.checkStatus().subscribe();
}
}

View File

@@ -1,74 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface AccountInfo {
username: string;
plexLinked: boolean;
plexUsername: string | null;
twoFactorEnabled: boolean;
apiKeyPreview: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface Regenerate2faRequest {
password: string;
totpCode: string;
}
export interface TotpSetupResponse {
secret: string;
qrCodeUri: string;
recoveryCodes: string[];
}
export interface PlexPinResponse {
pinId: number;
authUrl: string;
}
export interface PlexPinStatus {
completed: boolean;
plexUsername?: string;
}
@Injectable({ providedIn: 'root' })
export class AccountApi {
private http = inject(HttpClient);
getInfo(): Observable<AccountInfo> {
return this.http.get<AccountInfo>('/api/account');
}
changePassword(request: ChangePasswordRequest): Observable<void> {
return this.http.put<void>('/api/account/password', request);
}
regenerate2fa(request: Regenerate2faRequest): Observable<TotpSetupResponse> {
return this.http.post<TotpSetupResponse>('/api/account/2fa/regenerate', request);
}
getApiKey(): Observable<{ apiKey: string }> {
return this.http.get<{ apiKey: string }>('/api/account/api-key');
}
regenerateApiKey(): Observable<{ apiKey: string }> {
return this.http.post<{ apiKey: string }>('/api/account/api-key/regenerate', {});
}
linkPlex(): Observable<PlexPinResponse> {
return this.http.post<PlexPinResponse>('/api/account/plex/link', {});
}
verifyPlexLink(pinId: number): Observable<PlexPinStatus> {
return this.http.post<PlexPinStatus>('/api/account/plex/link/verify', { pinId });
}
unlinkPlex(): Observable<void> {
return this.http.delete<void>('/api/account/plex/link');
}
}

View File

@@ -1,56 +1,14 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { toObservable } from '@angular/core/rxjs-interop';
import { filter, map, take } from 'rxjs';
import { AuthService } from './auth.service';
/**
* Waits for the initial auth status check to complete,
* then evaluates the guard condition.
*/
function waitForAuth(guardFn: (auth: AuthService, router: Router) => boolean | import('@angular/router').UrlTree) {
const fn: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
// If already loaded, evaluate immediately
if (!auth.isLoading()) {
return guardFn(auth, router);
}
// Wait for loading to complete
return toObservable(auth.isLoading).pipe(
filter((loading) => !loading),
take(1),
map(() => guardFn(auth, router)),
);
};
return fn;
}
export const authGuard: CanActivateFn = waitForAuth((auth, router) => {
if (!auth.isSetupComplete()) {
return router.createUrlTree(['/auth/setup']);
}
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/auth/login']);
}
return true;
});
export const setupIncompleteGuard: CanActivateFn = waitForAuth((auth, router) => {
if (auth.isSetupComplete()) {
return router.createUrlTree(['/auth/login']);
}
return true;
});
export const loginGuard: CanActivateFn = waitForAuth((auth, router) => {
if (!auth.isSetupComplete()) {
return router.createUrlTree(['/auth/setup']);
}
if (auth.isAuthenticated()) {
return router.createUrlTree(['/dashboard']);
return true;
}
return true;
});
return router.createUrlTree(['/auth/login']);
};

View File

@@ -1,62 +1,7 @@
import { HttpInterceptorFn, HttpErrorResponse, HttpContextToken, HttpContext } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
const IS_RETRY = new HttpContextToken<boolean>(() => false);
import { HttpInterceptorFn } from '@angular/common/http';
// Placeholder: no-op interceptor. When auth is implemented, this will
// attach JWT/session tokens to outgoing requests and handle 401 responses.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Skip auth header for auth endpoints
if (req.url.includes('/api/auth/')) {
return next(req);
}
// Pre-flight: if token is expired, refresh before sending the request
if (auth.getAccessToken() && auth.isTokenExpired(30)) {
return auth.refreshToken().pipe(
switchMap((result) => {
if (result) {
const freshReq = req.clone({
setHeaders: { Authorization: `Bearer ${result.accessToken}` },
context: new HttpContext().set(IS_RETRY, true),
});
return next(freshReq);
}
auth.logout();
return throwError(() => new HttpErrorResponse({ status: 401 }));
}),
);
}
// Normal path: token is valid
const token = auth.getAccessToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
// Fallback: 401 catch for edge cases (e.g., token expired between check and send)
if (error.status === 401 && token && !req.context.get(IS_RETRY)) {
return auth.refreshToken().pipe(
switchMap((result) => {
if (result) {
const retryReq = req.clone({
setHeaders: { Authorization: `Bearer ${result.accessToken}` },
context: new HttpContext().set(IS_RETRY, true),
});
return next(retryReq);
}
auth.logout();
return throwError(() => error);
}),
);
}
return throwError(() => error);
}),
);
return next(req);
};

View File

@@ -1,292 +1,35 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, of, catchError, finalize, shareReplay } from 'rxjs';
import { Router } from '@angular/router';
import { Injectable, signal } from '@angular/core';
import { Observable, of } from 'rxjs';
export interface AuthStatus {
setupCompleted: boolean;
plexLinked: boolean;
export interface User {
id: string;
username: string;
}
export interface LoginResponse {
requiresTwoFactor: boolean;
loginToken?: string;
export interface LoginCredentials {
username: string;
password: string;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface TotpSetupResponse {
secret: string;
qrCodeUri: string;
recoveryCodes: string[];
}
export interface PlexPinResponse {
pinId: number;
authUrl: string;
}
export interface PlexVerifyResponse {
completed: boolean;
tokens?: TokenResponse;
export interface AuthResult {
success: boolean;
error?: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly _isAuthenticated = signal(false);
private readonly _isSetupComplete = signal(false);
private readonly _plexLinked = signal(false);
private readonly _isLoading = signal(true);
private readonly _isAuthenticated = signal(true);
private readonly _user = signal<User | null>(null);
readonly isAuthenticated = this._isAuthenticated.asReadonly();
readonly isSetupComplete = this._isSetupComplete.asReadonly();
readonly plexLinked = this._plexLinked.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly user = this._user.asReadonly();
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private refreshInFlight$: Observable<TokenResponse | null> | null = null;
private visibilityHandler: (() => void) | null = null;
checkStatus(): Observable<AuthStatus> {
return this.http.get<AuthStatus>('/api/auth/status').pipe(
tap((status) => {
this._isSetupComplete.set(status.setupCompleted);
this._plexLinked.set(status.plexLinked);
const token = localStorage.getItem('access_token');
if (token && status.setupCompleted) {
if (this.isTokenExpired(60)) {
// Access token expired — try to refresh before marking as authenticated
this.refreshToken().subscribe((result) => {
if (result) {
this._isAuthenticated.set(true);
this.setupVisibilityListener();
} else {
this._isAuthenticated.set(false);
this.router.navigate(['/auth/login']);
}
this._isLoading.set(false);
});
return;
}
this._isAuthenticated.set(true);
this.scheduleRefresh();
this.setupVisibilityListener();
}
this._isLoading.set(false);
}),
catchError(() => {
this._isLoading.set(false);
return of({ setupCompleted: false, plexLinked: false });
}),
);
}
// Setup flow
createAccount(username: string, password: string): Observable<{ userId: string }> {
return this.http.post<{ userId: string }>('/api/auth/setup/account', { username, password });
}
generateTotpSetup(): Observable<TotpSetupResponse> {
return this.http.post<TotpSetupResponse>('/api/auth/setup/2fa/generate', {});
}
verifyTotpSetup(code: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>('/api/auth/setup/2fa/verify', { code });
}
completeSetup(): Observable<{ message: string }> {
return this.http.post<{ message: string }>('/api/auth/setup/complete', {}).pipe(
tap(() => this._isSetupComplete.set(true)),
);
}
// Login flow
login(username: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', { username, password });
}
verify2fa(loginToken: string, code: string, isRecoveryCode = false): Observable<TokenResponse> {
return this.http
.post<TokenResponse>('/api/auth/login/2fa', { loginToken, code, isRecoveryCode })
.pipe(tap((tokens) => this.handleTokens(tokens)));
}
// Setup Plex linking
requestSetupPlexPin(): Observable<PlexPinResponse> {
return this.http.post<PlexPinResponse>('/api/auth/setup/plex/pin', {});
}
verifySetupPlexPin(pinId: number): Observable<PlexVerifyResponse> {
return this.http.post<PlexVerifyResponse>('/api/auth/setup/plex/verify', { pinId });
}
// Plex login
requestPlexPin(): Observable<PlexPinResponse> {
return this.http.post<PlexPinResponse>('/api/auth/login/plex/pin', {});
}
verifyPlexPin(pinId: number): Observable<PlexVerifyResponse> {
return this.http.post<PlexVerifyResponse>('/api/auth/login/plex/verify', { pinId }).pipe(
tap((result) => {
if (result.completed && result.tokens) {
this.handleTokens(result.tokens);
}
}),
);
}
// Token management
refreshToken(): Observable<TokenResponse | null> {
// Deduplicate: if a refresh is already in-flight, share the same observable
if (this.refreshInFlight$) {
return this.refreshInFlight$;
}
const storedRefreshToken = localStorage.getItem('refresh_token');
if (!storedRefreshToken) {
this.clearAuth();
return of(null);
}
this.refreshInFlight$ = this.http
.post<TokenResponse>('/api/auth/refresh', { refreshToken: storedRefreshToken })
.pipe(
tap((tokens) => this.handleTokens(tokens)),
catchError(() => {
this.clearAuth();
return of(null);
}),
finalize(() => {
this.refreshInFlight$ = null;
}),
shareReplay(1),
);
return this.refreshInFlight$;
login(_credentials: LoginCredentials): Observable<AuthResult> {
// Placeholder: always succeeds. Implement real auth later.
return of({ success: true });
}
logout(): void {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
this.http.post('/api/auth/logout', { refreshToken }).subscribe();
}
this.clearAuth();
this.router.navigate(['/auth/login']);
}
getAccessToken(): string | null {
return localStorage.getItem('access_token');
}
/** Returns true if the access token is expired or will expire within the buffer period. */
isTokenExpired(bufferSeconds = 30): boolean {
const token = localStorage.getItem('access_token');
if (!token) return true;
const exp = this.getTokenExpiry(token);
if (exp === null) return true;
return Date.now() / 1000 >= exp - bufferSeconds;
}
private handleTokens(tokens: TokenResponse): void {
localStorage.setItem('access_token', tokens.accessToken);
localStorage.setItem('refresh_token', tokens.refreshToken);
this._isAuthenticated.set(true);
this.scheduleRefresh();
this.setupVisibilityListener();
}
private scheduleRefresh(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
// Always derive from the JWT's actual exp claim — never trust ExpiresIn from response
const token = localStorage.getItem('access_token');
if (!token) return;
const exp = this.getTokenExpiry(token);
if (exp === null) return;
const remainingSec = exp - Date.now() / 1000;
if (remainingSec <= 30) {
this.refreshToken().subscribe();
return;
}
// Refresh at 80% of remaining lifetime
const refreshMs = remainingSec * 800;
this.refreshTimer = setTimeout(() => {
this.refreshToken().subscribe();
}, refreshMs);
}
private clearAuth(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
this._isAuthenticated.set(false);
this.refreshInFlight$ = null;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.teardownVisibilityListener();
}
/** Extracts the exp claim (seconds since epoch) from a JWT. */
private getTokenExpiry(token: string): number | null {
try {
const payload = token.split('.')[1];
if (!payload) return null;
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
const parsed = JSON.parse(decoded);
return typeof parsed.exp === 'number' ? parsed.exp : null;
} catch {
return null;
}
}
private setupVisibilityListener(): void {
if (this.visibilityHandler) return;
this.visibilityHandler = () => {
if (document.visibilityState !== 'visible' || !this._isAuthenticated()) {
return;
}
if (this.isTokenExpired(60)) {
// Token expired during sleep — refresh immediately
this.refreshToken().subscribe((result) => {
if (!result) {
this.router.navigate(['/auth/login']);
}
});
} else {
// Token still valid — reschedule timer (old setTimeout was frozen during sleep)
this.scheduleRefresh();
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
}
private teardownVisibilityListener(): void {
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
// Placeholder: no-op. Implement real logout later.
}
}

View File

@@ -1,11 +1,6 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export class ApiError extends Error {
retryAfterSeconds?: number;
statusCode?: number;
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
@@ -22,10 +17,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
?? `Error ${error.status}`;
}
const apiError = new ApiError(message);
apiError.retryAfterSeconds = error.error?.retryAfterSeconds;
apiError.statusCode = error.status;
return throwError(() => apiError);
return throwError(() => new Error(message));
}),
);
};

View File

@@ -1,14 +1,11 @@
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { firstValueFrom } from 'rxjs';
import { SignalRHubConfig } from '@core/models/signalr.models';
import { ApplicationPathService } from '@core/services/base-path.service';
import { AuthService } from '@core/auth/auth.service';
@Injectable()
export abstract class HubService implements OnDestroy {
private readonly pathService = inject(ApplicationPathService);
private readonly authService = inject(AuthService);
private connection: signalR.HubConnection | null = null;
private reconnectAttempts = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -27,18 +24,7 @@ export abstract class HubService implements OnDestroy {
const hubUrl = this.pathService.buildHubUrl(this.config.hubUrl);
this.connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: async () => {
if (this.authService.isTokenExpired(30)) {
const result = await firstValueFrom(this.authService.refreshToken());
if (result) {
return result.accessToken;
}
return '';
}
return this.authService.getAccessToken() ?? '';
},
})
.withUrl(hubUrl)
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
return Math.min(

View File

@@ -1,130 +1,18 @@
<h2 class="login-title">Sign In</h2>
<h1 class="login-title">Welcome to Cleanuparr</h1>
<p class="login-subtitle">Sign in to continue</p>
@if (error()) {
<div class="error-message">{{ error() }}</div>
}
@if (retryCountdown() > 0) {
<div class="retry-countdown">Try again in {{ retryCountdown() }}s</div>
}
<!-- Credentials view -->
@if (view() === 'credentials') {
<div class="login-view">
<form class="login-form" (ngSubmit)="submitLogin()">
<app-input
#usernameInput
label="Username"
placeholder="Enter your username"
type="text"
[value]="username()"
(valueChange)="username.set($event)"
/>
<app-input
label="Password"
placeholder="Enter your password"
type="password"
[value]="password()"
(valueChange)="password.set($event)"
/>
<app-button
variant="primary"
class="login-submit"
[disabled]="!username() || !password() || loading() || retryCountdown() > 0"
type="submit"
>
@if (loading()) {
<app-spinner size="sm" />
} @else {
Sign In
}
</app-button>
</form>
@if (plexLinked()) {
<div class="divider">
<span>or</span>
</div>
<button
class="plex-login-btn"
[disabled]="plexLoading()"
(click)="startPlexLogin()"
>
@if (plexLoading()) {
<app-spinner size="sm" />
Waiting for Plex...
} @else {
<svg class="plex-login-btn__icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.074 1L12 12 5.074 23h5.854L18.926 12 11.928 1z"/>
</svg>
Sign in with Plex
}
</button>
}
</div>
}
<!-- 2FA view -->
@if (view() === '2fa') {
<div class="login-view">
<form class="login-form" (ngSubmit)="submit2fa()">
<app-input
#totpInput
label="Authentication Code"
placeholder="Enter 6-digit code"
type="text"
[value]="totpCode()"
(valueChange)="totpCode.set($event)"
/>
<app-button
variant="primary"
class="login-submit"
[disabled]="totpCode().length !== 6 || loading()"
type="submit"
>
@if (loading()) {
<app-spinner size="sm" />
} @else {
Verify
}
</app-button>
</form>
<div class="login-links">
<button class="text-link" (click)="useRecoveryCode()">Use a recovery code</button>
<button class="text-link" (click)="backToCredentials()">Back to sign in</button>
</div>
</div>
}
<!-- Recovery code view -->
@if (view() === 'recovery') {
<div class="login-view">
<form class="login-form" (ngSubmit)="submitRecoveryCode()">
<app-input
#recoveryInput
label="Recovery Code"
placeholder="XXXX-XXXX"
type="text"
[value]="recoveryCode()"
(valueChange)="recoveryCode.set($event)"
/>
<app-button
variant="primary"
class="login-submit"
[disabled]="!recoveryCode() || loading()"
type="submit"
>
@if (loading()) {
<app-spinner size="sm" />
} @else {
Verify Recovery Code
}
</app-button>
</form>
<div class="login-links">
<button class="text-link" (click)="backTo2fa()">Back to authenticator code</button>
</div>
</div>
}
<form class="login-form">
<app-input
label="Username"
placeholder="Enter your username"
type="text"
/>
<app-input
label="Password"
placeholder="Enter your password"
type="password"
/>
<app-button variant="primary" class="login-submit">
Sign In
</app-button>
</form>

View File

@@ -1,51 +1,16 @@
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-view {
animation: fadeSlideIn var(--duration-normal) var(--ease-default);
}
@media (prefers-reduced-motion: reduce) {
.login-view {
animation: none;
}
}
.login-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
text-align: center;
margin-bottom: var(--space-6);
margin-bottom: var(--space-2);
}
.error-message {
background: rgba(239, 68, 68, 0.1);
color: var(--color-error);
padding: var(--space-3);
border-radius: var(--radius-md);
.login-subtitle {
font-size: var(--font-size-sm);
margin-bottom: var(--space-4);
text-align: center;
}
.retry-countdown {
background: rgba(234, 179, 8, 0.1);
color: var(--color-warning);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-4);
color: var(--text-secondary);
text-align: center;
margin-bottom: var(--space-8);
}
.login-form {
@@ -56,87 +21,5 @@
.login-submit {
margin-top: var(--space-2);
align-self: flex-end;
}
.divider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-6) 0;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-primary);
}
span {
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-transform: lowercase;
}
}
.plex-login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
height: 44px;
padding: 0 var(--space-4);
font-family: var(--font-family);
font-size: var(--font-size-sm);
font-weight: 500;
color: #1a1a2e;
background: #e5a00d;
border: 1px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
&:hover:not(:disabled) {
background: #d4920c;
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
}
&:active:not(:disabled) {
transform: scale(0.97);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&__icon {
width: 18px;
height: 18px;
}
}
.login-links {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-4);
}
.text-link {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--font-size-sm);
cursor: pointer;
padding: 0;
text-decoration: none;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
}

View File

@@ -1,214 +1,11 @@
import { Component, ChangeDetectionStrategy, inject, signal, viewChild, effect, afterNextRender, OnInit, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
import { AuthService } from '@core/auth/auth.service';
import { ApiError } from '@core/interceptors/error.interceptor';
type LoginView = 'credentials' | '2fa' | 'recovery';
import { Component } from '@angular/core';
import { ButtonComponent, InputComponent } from '@ui';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent],
imports: [ButtonComponent, InputComponent],
templateUrl: './login.component.html',
styleUrl: './login.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginComponent implements OnInit, OnDestroy {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
view = signal<LoginView>('credentials');
loading = signal(false);
error = signal('');
// Credentials
username = signal('');
password = signal('');
// 2FA
loginToken = signal('');
totpCode = signal('');
recoveryCode = signal('');
// Retry countdown
retryCountdown = signal(0);
private countdownTimer: ReturnType<typeof setInterval> | null = null;
// Plex
plexLinked = this.auth.plexLinked;
plexLoading = signal(false);
plexPinId = signal(0);
// Auto-focus refs
usernameInput = viewChild<InputComponent>('usernameInput');
totpInput = viewChild<InputComponent>('totpInput');
recoveryInput = viewChild<InputComponent>('recoveryInput');
constructor() {
// Auto-focus username input on initial render
afterNextRender(() => {
this.usernameInput()?.focus();
});
// Auto-focus on view change
effect(() => {
const currentView = this.view();
if (currentView === '2fa') {
setTimeout(() => this.totpInput()?.focus());
} else if (currentView === 'recovery') {
setTimeout(() => this.recoveryInput()?.focus());
}
});
}
ngOnInit(): void {
this.auth.checkStatus().subscribe();
}
ngOnDestroy(): void {
this.clearCountdown();
if (this.plexPollTimer) {
clearInterval(this.plexPollTimer);
}
}
submitLogin(): void {
this.loading.set(true);
this.error.set('');
this.auth.login(this.username(), this.password()).subscribe({
next: (result) => {
if (result.requiresTwoFactor && result.loginToken) {
this.loginToken.set(result.loginToken);
this.view.set('2fa');
}
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Invalid credentials');
this.loading.set(false);
const retryAfter = (err as ApiError).retryAfterSeconds;
if (retryAfter && retryAfter > 0) {
this.startCountdown(retryAfter);
}
},
});
}
submit2fa(): void {
this.loading.set(true);
this.error.set('');
this.auth.verify2fa(this.loginToken(), this.totpCode()).subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err.message || 'Invalid code');
this.loading.set(false);
},
});
}
submitRecoveryCode(): void {
this.loading.set(true);
this.error.set('');
this.auth.verify2fa(this.loginToken(), this.recoveryCode(), true).subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err.message || 'Invalid recovery code');
this.loading.set(false);
},
});
}
useRecoveryCode(): void {
this.view.set('recovery');
this.error.set('');
}
backTo2fa(): void {
this.view.set('2fa');
this.error.set('');
}
backToCredentials(): void {
this.view.set('credentials');
this.error.set('');
this.loginToken.set('');
}
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
startPlexLogin(): void {
this.plexLoading.set(true);
this.error.set('');
this.auth.requestPlexPin().subscribe({
next: (result) => {
this.plexPinId.set(result.pinId);
window.open(result.authUrl, '_blank');
this.pollPlexPin();
},
error: (err) => {
this.error.set(err.message || 'Failed to start Plex login');
this.plexLoading.set(false);
},
});
}
private pollPlexPin(): void {
let attempts = 0;
this.plexPollTimer = setInterval(() => {
attempts++;
if (attempts > 60) {
clearInterval(this.plexPollTimer!);
this.plexLoading.set(false);
this.error.set('Plex authorization timed out');
return;
}
this.auth.verifyPlexPin(this.plexPinId()).subscribe({
next: (result) => {
if (result.completed) {
clearInterval(this.plexPollTimer!);
this.plexLoading.set(false);
this.router.navigate(['/dashboard']);
}
},
error: (err) => {
clearInterval(this.plexPollTimer!);
this.plexLoading.set(false);
this.error.set(err.message || 'Plex authorization failed');
},
});
}, 2000);
}
private startCountdown(seconds: number): void {
this.clearCountdown();
this.retryCountdown.set(seconds);
this.countdownTimer = setInterval(() => {
const current = this.retryCountdown();
if (current <= 1) {
this.clearCountdown();
} else {
this.retryCountdown.set(current - 1);
}
}, 1000);
}
private clearCountdown(): void {
this.retryCountdown.set(0);
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
}
}
export class LoginComponent {}

View File

@@ -1,236 +0,0 @@
<h2 class="setup-title">Initial Setup</h2>
<div class="steps-indicator">
<div class="step-group" [class.active]="currentStep() === 1" [class.completed]="currentStep() > 1">
<div class="step-marker">
@if (currentStep() > 1) {
<ng-icon name="tablerCheck" size="14" />
} @else {
1
}
</div>
<span class="step-label">Account</span>
</div>
<div class="step-connector" [class.active]="currentStep() > 1"></div>
<div class="step-group" [class.active]="currentStep() === 2" [class.completed]="currentStep() > 2">
<div class="step-marker">
@if (currentStep() > 2) {
<ng-icon name="tablerCheck" size="14" />
} @else {
2
}
</div>
<span class="step-label">2FA</span>
</div>
<div class="step-connector" [class.active]="currentStep() > 2"></div>
<div class="step-group" [class.active]="currentStep() === 3" [class.completed]="currentStep() > 3">
<div class="step-marker">3</div>
<span class="step-label">Plex</span>
</div>
</div>
@if (error()) {
<div class="error-message">{{ error() }}</div>
}
<!-- Step 1: Create Account -->
@if (currentStep() === 1) {
<div class="step-content">
<h2 class="step-title">Create Admin Account</h2>
<p class="step-subtitle">Choose a username and strong password</p>
<form class="setup-form" (ngSubmit)="createAccount()">
<app-input
#usernameInput
label="Username"
placeholder="Enter a username"
type="text"
[value]="username()"
(valueChange)="username.set($event)"
/>
<app-input
label="Password"
placeholder="Minimum 8 characters"
type="password"
[value]="password()"
(valueChange)="password.set($event)"
/>
@if (password()) {
<div class="password-strength">
<div class="password-strength__bar">
<div class="password-strength__fill password-strength__fill--{{ passwordStrength() }}"></div>
</div>
<span class="password-strength__label password-strength__label--{{ passwordStrength() }}">{{ passwordStrength() }}</span>
</div>
}
<app-input
label="Confirm Password"
placeholder="Re-enter your password"
type="password"
[value]="confirmPassword()"
(valueChange)="confirmPassword.set($event)"
/>
@if (password() && !passwordValid) {
<p class="field-error">Password must be at least 8 characters</p>
}
@if (confirmPassword() && !passwordsMatch) {
<p class="field-error">Passwords do not match</p>
}
<app-button
variant="primary"
class="submit-btn"
[disabled]="!username() || !passwordValid || !passwordsMatch || loading()"
type="submit"
>
@if (loading()) {
<app-spinner size="sm" />
} @else {
Continue
}
</app-button>
</form>
</div>
}
<!-- Step 2: Two-Factor Authentication -->
@if (currentStep() === 2) {
<div class="step-content">
<h2 class="step-title">
<ng-icon name="tablerShieldLock" size="20" />
Two-Factor Authentication
</h2>
@if (!totpVerified()) {
<p class="step-subtitle">Scan the QR code with your authenticator app</p>
@if (loading()) {
<div class="loading-center">
<app-spinner />
</div>
} @else {
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="qrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ totpSecret() }}</code>
</div>
</details>
</div>
<form class="setup-form" (ngSubmit)="verifyTotp()">
<app-input
#verificationInput
label="Verification Code"
placeholder="Enter 6-digit code"
type="text"
[value]="verificationCode()"
(valueChange)="verificationCode.set($event)"
/>
<app-button
variant="primary"
class="submit-btn"
[disabled]="verificationCode().length !== 6"
type="submit"
>
Verify Code
</app-button>
</form>
}
} @else {
<p class="step-subtitle success-text">
<ng-icon name="tablerCheck" size="16" />
2FA verified successfully!
</p>
<div class="recovery-section">
<h3 class="recovery-title">Save Your Recovery Codes</h3>
<p class="recovery-desc">Store these codes in a safe place. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of recoveryCodes(); track code) {
<code class="recovery-code">{{ code }}</code>
}
</div>
<div class="recovery-actions">
<app-button variant="secondary" (click)="copyRecoveryCodes()">
<ng-icon name="tablerCopy" size="16" />
Copy Codes
</app-button>
<app-button variant="secondary" (click)="downloadRecoveryCodes()">
Download
</app-button>
</div>
<label class="checkbox-label">
<input type="checkbox" [checked]="codesSaved()" (change)="codesSaved.set(!codesSaved())" />
I have saved my recovery codes
</label>
<app-button
variant="primary"
class="submit-btn"
[disabled]="!codesSaved()"
(click)="goToStep3()"
>
Continue
</app-button>
</div>
}
</div>
}
<!-- Step 3: Plex (Optional) -->
@if (currentStep() === 3) {
<div class="step-content">
<h2 class="step-title">Link Plex Account</h2>
<p class="step-subtitle">Optional: Link your Plex account for quick sign-in</p>
@if (!plexLinked()) {
<button
class="plex-btn"
[disabled]="plexLinking()"
(click)="startPlexLink()"
>
@if (plexLinking()) {
<app-spinner size="sm" />
Waiting for Plex authorization...
} @else {
<svg class="plex-btn__icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.074 1L12 12 5.074 23h5.854L18.926 12 11.928 1z"/>
</svg>
Link Plex Account
}
</button>
} @else {
<div class="plex-success">
<ng-icon name="tablerCheck" size="16" />
Plex account linked
</div>
}
<div class="step-actions">
<app-button
variant="primary"
class="submit-btn"
[disabled]="loading()"
(click)="completeSetup()"
>
@if (loading()) {
<app-spinner size="sm" />
} @else {
Complete Setup
}
</app-button>
</div>
</div>
}

View File

@@ -1,374 +0,0 @@
// Animations
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.setup-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
text-align: center;
margin-bottom: var(--space-6);
}
// Timeline indicator
.steps-indicator {
display: flex;
align-items: flex-start;
justify-content: center;
margin-bottom: var(--space-6);
}
.step-group {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.step-marker {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
background: rgba(148, 163, 184, 0.15);
color: var(--text-tertiary);
transition: all var(--duration-normal) var(--ease-default);
.step-group.active & {
background: rgba(126, 87, 194, 0.15);
color: var(--color-primary);
box-shadow: 0 0 8px rgba(126, 87, 194, 0.25);
}
.step-group.completed & {
background: var(--color-primary);
color: #ffffff;
box-shadow: 0 0 12px rgba(126, 87, 194, 0.35);
}
}
.step-label {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
transition: color var(--duration-normal) var(--ease-default);
.step-group.active & {
color: var(--color-primary);
}
.step-group.completed & {
color: var(--text-secondary);
}
}
.step-connector {
width: 48px;
height: 2px;
background: rgba(148, 163, 184, 0.15);
margin-top: 13px; // center vertically with 28px marker
transition: background var(--duration-normal) var(--ease-default);
&.active {
background: var(--color-primary);
}
}
// Step content
.step-content {
display: flex;
flex-direction: column;
animation: fadeSlideIn var(--duration-normal) var(--ease-default);
}
@media (prefers-reduced-motion: reduce) {
.step-content {
animation: none;
}
}
.step-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-1);
display: flex;
align-items: center;
gap: var(--space-2);
}
.step-subtitle {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
.success-text {
color: var(--color-success);
display: flex;
align-items: center;
gap: var(--space-1);
}
.error-message {
background: rgba(239, 68, 68, 0.1);
color: var(--color-error);
padding: var(--space-3);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
margin-bottom: var(--space-4);
text-align: center;
}
.setup-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.field-error {
font-size: var(--font-size-xs);
color: var(--color-error);
margin-top: calc(-1 * var(--space-2));
}
.submit-btn {
margin-top: var(--space-2);
align-self: flex-end;
}
.loading-center {
display: flex;
justify-content: center;
padding: var(--space-8) 0;
}
// Password strength indicator
.password-strength {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: calc(-1 * var(--space-2));
&__bar {
flex: 1;
height: 4px;
background: rgba(148, 163, 184, 0.15);
border-radius: var(--radius-full);
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: var(--radius-full);
transition: width var(--duration-normal) var(--ease-default),
background-color var(--duration-normal) var(--ease-default);
&--weak {
width: 33%;
background-color: var(--color-error);
}
&--medium {
width: 66%;
background-color: var(--color-warning);
}
&--strong {
width: 100%;
background-color: var(--color-success);
}
}
&__label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: capitalize;
min-width: 52px;
&--weak {
color: var(--color-error);
}
&--medium {
color: var(--color-warning);
}
&--strong {
color: var(--color-success);
}
}
}
// QR code section
.qr-section {
margin-bottom: var(--space-6);
}
.qr-code-wrapper {
display: flex;
justify-content: center;
margin-bottom: var(--space-4);
qrcode {
background: #ffffff;
padding: var(--space-3);
border-radius: var(--radius-lg);
}
}
.qr-manual-entry {
font-size: var(--font-size-sm);
color: var(--text-secondary);
summary {
cursor: pointer;
text-align: center;
margin-bottom: var(--space-2);
&:hover {
color: var(--text-primary);
}
}
}
.qr-manual-content {
background: var(--surface-secondary);
border-radius: var(--radius-md);
padding: var(--space-3);
text-align: center;
}
.qr-manual-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.qr-secret {
font-size: var(--font-size-sm);
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
}
// Recovery codes
.recovery-section {
margin-top: var(--space-4);
}
.recovery-title {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.recovery-desc {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.recovery-codes {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.recovery-code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--text-primary);
background: var(--surface-secondary);
padding: var(--space-2);
border-radius: var(--radius-sm);
text-align: center;
}
.recovery-actions {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
margin-bottom: var(--space-4);
input[type='checkbox'] {
accent-color: var(--color-primary);
}
}
// Plex section
.plex-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
height: 44px;
padding: 0 var(--space-4);
font-family: var(--font-family);
font-size: var(--font-size-sm);
font-weight: 500;
color: #1a1a2e;
background: #e5a00d;
border: 1px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
margin-bottom: var(--space-4);
&:hover:not(:disabled) {
background: #d4920c;
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
}
&:active:not(:disabled) {
transform: scale(0.97);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.plex-btn__icon {
width: 18px;
height: 18px;
}
.plex-success {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-success);
font-size: var(--font-size-sm);
margin-bottom: var(--space-4);
}
.step-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-4);
}

View File

@@ -1,233 +0,0 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
import { AuthService } from '@core/auth/auth.service';
import { ToastService } from '@core/services/toast.service';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { tablerCheck, tablerCopy, tablerShieldLock } from '@ng-icons/tabler-icons';
import { QRCodeComponent } from 'angularx-qrcode';
@Component({
selector: 'app-setup',
standalone: true,
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent, NgIconComponent, QRCodeComponent],
templateUrl: './setup.component.html',
styleUrl: './setup.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [provideIcons({ tablerCheck, tablerCopy, tablerShieldLock })],
})
export class SetupComponent implements OnDestroy {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly toast = inject(ToastService);
currentStep = signal(1);
loading = signal(false);
error = signal('');
// Step 1 - Account
username = signal('');
password = signal('');
confirmPassword = signal('');
// Step 2 - 2FA
totpSecret = signal('');
qrCodeUri = signal('');
recoveryCodes = signal<string[]>([]);
verificationCode = signal('');
totpVerified = signal(false);
codesSaved = signal(false);
// Step 3 - Plex
plexLinking = signal(false);
plexLinked = signal(false);
plexUsername = signal('');
plexPinId = signal(0);
// Auto-focus refs
usernameInput = viewChild<InputComponent>('usernameInput');
verificationInput = viewChild<InputComponent>('verificationInput');
// Password strength
passwordStrength = computed(() => {
const pw = this.password();
if (!pw) return null;
if (pw.length < 8) return 'weak';
const hasUpper = /[A-Z]/.test(pw);
const hasLower = /[a-z]/.test(pw);
const hasNumber = /[0-9]/.test(pw);
const hasSpecial = /[^A-Za-z0-9]/.test(pw);
const score = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
if (pw.length >= 12 && score >= 3) return 'strong';
if (pw.length >= 8 && score >= 2) return 'medium';
return 'weak';
});
get passwordsMatch(): boolean {
return this.password() === this.confirmPassword();
}
get passwordValid(): boolean {
return this.password().length >= 8;
}
constructor() {
// Auto-focus username input on initial render
afterNextRender(() => {
this.usernameInput()?.focus();
});
// Auto-focus verification input when entering step 2
effect(() => {
const step = this.currentStep();
if (step === 2) {
setTimeout(() => this.verificationInput()?.focus());
}
});
}
// Step 1: Create account
createAccount(): void {
if (!this.passwordsMatch || !this.passwordValid) return;
this.loading.set(true);
this.error.set('');
this.auth.createAccount(this.username(), this.password()).subscribe({
next: () => {
this.currentStep.set(2);
this.generateTotp();
},
error: (err) => {
this.error.set(err.message || 'Failed to create account');
this.loading.set(false);
},
});
}
// Step 2: Generate TOTP
private generateTotp(): void {
this.loading.set(true);
this.auth.generateTotpSetup().subscribe({
next: (result) => {
this.totpSecret.set(result.secret);
this.qrCodeUri.set(result.qrCodeUri);
this.recoveryCodes.set(result.recoveryCodes);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to generate 2FA');
this.loading.set(false);
},
});
}
verifyTotp(): void {
this.loading.set(true);
this.error.set('');
this.auth.verifyTotpSetup(this.verificationCode()).subscribe({
next: () => {
this.totpVerified.set(true);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Invalid code');
this.loading.set(false);
},
});
}
goToStep3(): void {
this.currentStep.set(3);
this.error.set('');
}
copyRecoveryCodes(): void {
const text = this.recoveryCodes().join('\n');
navigator.clipboard.writeText(text);
this.toast.success('Recovery codes copied to clipboard');
}
downloadRecoveryCodes(): void {
const text = this.recoveryCodes().join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cleanuparr-recovery-codes.txt';
a.click();
URL.revokeObjectURL(url);
}
// Step 3: Plex linking
startPlexLink(): void {
this.plexLinking.set(true);
this.error.set('');
this.auth.requestSetupPlexPin().subscribe({
next: (result) => {
this.plexPinId.set(result.pinId);
window.open(result.authUrl, '_blank');
this.pollPlexPin();
},
error: (err) => {
this.error.set(err.message || 'Failed to start Plex link');
this.plexLinking.set(false);
},
});
}
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
ngOnDestroy(): void {
if (this.plexPollTimer) {
clearInterval(this.plexPollTimer);
}
}
private pollPlexPin(): void {
let attempts = 0;
this.plexPollTimer = setInterval(() => {
attempts++;
if (attempts > 60) {
// Timeout after ~2 minutes
clearInterval(this.plexPollTimer!);
this.plexLinking.set(false);
this.error.set('Plex authorization timed out');
return;
}
this.auth.verifySetupPlexPin(this.plexPinId()).subscribe({
next: (result) => {
if (result.completed) {
clearInterval(this.plexPollTimer!);
this.plexLinked.set(true);
this.plexLinking.set(false);
}
},
error: (err) => {
clearInterval(this.plexPollTimer!);
this.plexLinking.set(false);
this.error.set(err.message || 'Plex linking failed');
},
});
}, 2000);
}
completeSetup(): void {
this.loading.set(true);
this.error.set('');
this.auth.completeSetup().subscribe({
next: () => {
this.router.navigate(['/auth/login']);
},
error: (err) => {
this.error.set(err.message || 'Failed to complete setup');
this.loading.set(false);
},
});
}
}

View File

@@ -1,226 +0,0 @@
<app-page-header
title="Account Settings"
subtitle="Manage your account, security, and integrations"
/>
@if (loadError()) {
<app-empty-state
icon="tablerPlugConnectedX"
heading="Could not connect to server"
description="Failed to load account settings. Please check that the server is running and try again."
>
<app-button variant="primary" size="sm" (clicked)="retry()">
Retry
</app-button>
</app-empty-state>
} @else if (loader.showSpinner()) {
<app-loading-state message="Loading account settings..." />
} @else if (!loader.loading() && account()) {
<div class="settings-form">
<!-- Change Password -->
<app-card header="Change Password">
<div class="form-stack">
<app-input
label="Current Password"
type="password"
placeholder="Enter current password"
[value]="currentPassword()"
(valueChange)="currentPassword.set($event)"
/>
<app-input
label="New Password"
type="password"
placeholder="Enter new password (min 8 characters)"
[value]="newPassword()"
(valueChange)="newPassword.set($event)"
/>
@if (newPassword()) {
<div class="password-strength">
<div class="password-strength__bar">
<div class="password-strength__fill password-strength__fill--{{ newPasswordStrength() }}"></div>
</div>
<span class="password-strength__label password-strength__label--{{ newPasswordStrength() }}">{{ newPasswordStrength() }}</span>
</div>
}
<app-input
label="Confirm New Password"
type="password"
placeholder="Confirm new password"
[value]="confirmPassword()"
(valueChange)="confirmPassword.set($event)"
/>
<div class="form-actions">
<app-button
variant="primary"
[glowing]="!!currentPassword() && !!newPassword() && !!confirmPassword()"
[disabled]="!currentPassword() || !newPassword() || !confirmPassword() || changingPassword()"
(clicked)="changePassword()"
>
@if (changingPassword()) {
<app-spinner size="sm" /> Changing...
} @else {
Change Password
}
</app-button>
</div>
</div>
</app-card>
<!-- Two-Factor Authentication -->
<app-card header="Two-Factor Authentication">
<div class="form-stack">
<div class="status-row">
<span class="status-label">Status</span>
<span class="status-value status-value--active">Active</span>
</div>
@if (newRecoveryCodes().length > 0) {
<div class="recovery-section">
<p class="recovery-title">New Authenticator Setup</p>
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
<div class="qr-section">
<div class="qr-code-wrapper">
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
</div>
<details class="qr-manual-entry">
<summary>Can't scan? Enter manually</summary>
<div class="qr-manual-content">
<p class="qr-manual-label">Secret key:</p>
<code class="qr-secret">{{ newTotpSecret() }}</code>
</div>
</details>
</div>
<div class="form-divider"></div>
<p class="recovery-title">New Recovery Codes</p>
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
<div class="recovery-codes">
@for (code of newRecoveryCodes(); track code) {
<div class="recovery-code">{{ code }}</div>
}
</div>
<div class="recovery-actions">
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
</div>
</div>
} @else {
<div class="form-divider"></div>
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
<app-input
label="Current Password"
type="password"
placeholder="Enter your password"
[value]="twoFaPassword()"
(valueChange)="twoFaPassword.set($event)"
/>
<app-input
label="Authenticator Code"
type="text"
placeholder="Enter 6-digit code"
[value]="twoFaCode()"
(valueChange)="twoFaCode.set($event)"
/>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
(clicked)="confirmRegenerate2fa()"
>
@if (regenerating2fa()) {
<app-spinner size="sm" /> Regenerating...
} @else {
Regenerate 2FA
}
</app-button>
</div>
}
</div>
</app-card>
<!-- API Key -->
<app-card header="API Key">
<div class="form-stack">
<p class="section-hint">
Use this API key to access the Cleanuparr API without authentication.
Include it as the <code>X-Api-Key</code> header or <code>?apikey=</code> query parameter.
</p>
<div class="api-key-row">
<div class="api-key-display">
@if (apiKeyRevealed()) {
<code class="api-key-value">{{ apiKey() }}</code>
} @else {
<code class="api-key-value api-key-value--masked">{{ account()!.apiKeyPreview }}</code>
}
</div>
<div class="api-key-actions">
<app-button variant="ghost" size="sm" (clicked)="revealApiKey()">
{{ apiKeyRevealed() ? 'Hide' : 'Reveal' }}
</app-button>
@if (apiKeyRevealed()) {
<app-button variant="ghost" size="sm" (clicked)="copyApiKey()">Copy</app-button>
}
</div>
</div>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="regeneratingApiKey()"
(clicked)="confirmRegenerateApiKey()"
>
@if (regeneratingApiKey()) {
<app-spinner size="sm" /> Regenerating...
} @else {
Regenerate API Key
}
</app-button>
</div>
</div>
</app-card>
<!-- Plex Integration -->
<app-card header="Plex Integration">
<div class="form-stack">
@if (account()!.plexLinked) {
<div class="status-row">
<span class="status-label">Linked Account</span>
<span class="status-value">{{ account()!.plexUsername }}</span>
</div>
<div class="form-actions">
<app-button
variant="destructive"
[disabled]="plexUnlinking()"
(clicked)="confirmUnlinkPlex()"
>
@if (plexUnlinking()) {
<app-spinner size="sm" /> Unlinking...
} @else {
Unlink Plex Account
}
</app-button>
</div>
} @else {
<p class="section-hint">
Link your Plex account to enable signing in with Plex as an alternative to username and password.
</p>
<div class="form-actions">
<app-button
variant="primary"
[disabled]="plexLinking()"
(clicked)="startPlexLink()"
>
@if (plexLinking()) {
<app-spinner size="sm" /> Waiting for Plex...
} @else {
Link Plex Account
}
</app-button>
</div>
}
</div>
</app-card>
</div>
}

Some files were not shown because too many files have changed in this diff Show More