Compare commits

..

28 Commits

Author SHA1 Message Date
Flaminel
18dc0bb7e4 lowered the token lifetime for testing 2026-02-16 11:17:20 +02:00
Flaminel
dd38b576f7 added pre-flight fix for expired tokens 2026-02-16 11:16:57 +02:00
Flaminel
94215cee00 Revert "lowered the token lifetime for testing"
This reverts commit 197bd0d444.
2026-02-15 22:56:36 +02:00
Flaminel
197bd0d444 lowered the token lifetime for testing 2026-02-15 22:43:43 +02:00
Flaminel
d20773ab7b fixed scheme challenging 2026-02-15 22:43:28 +02:00
Flaminel
f4e92a68ee fixed frontend being logged in while backend rejected requests 2026-02-15 21:10:14 +02:00
Flaminel
18dc2813eb added glowing for change password button 2026-02-15 19:44:00 +02:00
Flaminel
63ef979d0d increased log level for data protection namespace 2026-02-15 18:44:59 +02:00
Flaminel
a72f01fe4c fixed data protection keys warnings 2026-02-15 18:29:28 +02:00
Flaminel
9699e0fc29 fixed frontend serving not being allowed for unauthenticated requests 2026-02-15 18:15:07 +02:00
Flaminel
0be7e125c9 fixed some stuff 2026-02-15 15:43:11 +02:00
Flaminel
49f0ce9969 fixed properties mismatch 2026-02-15 15:42:45 +02:00
Flaminel
4d8e27b01e fixed health endpoints not being anonymous 2026-02-15 15:42:23 +02:00
Flaminel
d822f7ef32 removed unused service injection 2026-02-15 15:42:03 +02:00
Flaminel
3d7ed0e702 fixed default authorization policy 2026-02-15 15:41:49 +02:00
Flaminel
f514523de1 fixed SignalR endpoints not being protected 2026-02-15 15:41:16 +02:00
Flaminel
7160838ab4 added auth integration tests 2026-02-15 15:40:45 +02:00
Flaminel
cf495b5aac fixed package lock file 2026-02-15 14:06:38 +02:00
Flaminel
6388677244 fixed useless polling when logging in with a different Plex account 2026-02-15 14:00:49 +02:00
Flaminel
9d46c0ae12 improved design and added brute force guard 2026-02-15 13:51:00 +02:00
Flaminel
dad8dd9eee improved design and fixed plex setup 2026-02-15 13:38:11 +02:00
Flaminel
5ea3b5273f improved design and setup flow 2026-02-15 13:15:14 +02:00
Flaminel
8864207b8e added authentication 2026-02-15 13:15:06 +02:00
Flaminel
94acd9afa4 Fix download client inputs (#442) 2026-02-15 04:06:28 +02:00
Flaminel
65d25a72a9 Fix failed import reason display for events (#443) 2026-02-15 03:59:40 +02:00
Flaminel
97eb2fce44 Add strikes page (#438) 2026-02-15 03:57:14 +02:00
Flaminel
701829001c Fix speed and size inputs for queue rules (#440) 2026-02-15 01:19:32 +02:00
Flaminel
8aeeca111c Add strike persistency (#437) 2026-02-14 04:00:05 +02:00
201 changed files with 11990 additions and 1022 deletions

View File

@@ -15,6 +15,12 @@ ifndef name
endif
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
migrate-users:
ifndef name
$(error name is required. Usage: make migrate-users name=YourMigrationName)
endif
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
docker-build:
ifndef tag
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Api.Tests;
/// <summary>
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
/// The database file is created in a temp directory so both DI and static contexts share the same data.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _tempDir;
public CustomWebApplicationFactory()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Remove the existing UsersContext registration
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
if (descriptor != null) services.Remove(descriptor);
// Also remove the DbContext registration itself
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
if (contextDescriptor != null) services.Remove(contextDescriptor);
var dbPath = Path.Combine(_tempDir, "users.db");
services.AddDbContext<UsersContext>(options =>
{
options.UseSqlite($"Data Source={dbPath}");
});
// Ensure DB is created
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
db.Database.EnsureCreated();
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_tempDir))
{
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
}
}
}

View File

@@ -0,0 +1,232 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for the authentication flow.
/// Uses a single shared factory to avoid static state conflicts.
/// Tests are ordered to build on each other: setup → login → protected endpoints.
/// </summary>
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public AuthControllerTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact, TestPriority(0)]
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
}
[Fact, TestPriority(1)]
public async Task Setup_CreateAccount_ReturnsCreated()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(2)]
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "another",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
}
[Fact, TestPriority(3)]
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
// Store the secret for the next test
_totpSecret = body.GetProperty("secret").GetString()!;
}
[Fact, TestPriority(4)]
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
{
// If we don't have the secret from the previous test, generate it again
if (string.IsNullOrEmpty(_totpSecret))
{
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
_totpSecret = genBody.GetProperty("secret").GetString()!;
}
var code = GenerateTotpCode(_totpSecret);
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(5)]
public async Task Setup_Complete_Succeeds()
{
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(6)]
public async Task Login_ValidCredentials_RequiresTwoFactor()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(7)]
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "WrongPassword!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact, TestPriority(8)]
public async Task Login_BruteForce_ReturnsRetryAfter()
{
// Make multiple failed attempts
for (int i = 0; i < 3; i++)
{
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "WrongPassword!"
});
}
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "WrongPassword!"
});
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
}
else
{
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
retry.GetInt32().ShouldBeGreaterThan(0);
}
}
[Fact, TestPriority(9)]
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
{
var response = await _client.GetAsync("/api/account");
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
.ShouldContain(response.StatusCode);
}
[Fact, TestPriority(10)]
public async Task HealthEndpoint_WithoutAuth_Returns200()
{
var response = await _client.GetAsync("/health");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
#region TOTP helpers
private static string _totpSecret = "";
private static string GenerateTotpCode(string base32Secret)
{
var key = Base32Decode(base32Secret);
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
var timestepBytes = BitConverter.GetBytes(timestep);
if (BitConverter.IsLittleEndian)
Array.Reverse(timestepBytes);
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
var hash = hmac.ComputeHash(timestepBytes);
var offset = hash[^1] & 0x0F;
var binaryCode =
((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
return (binaryCode % 1_000_000).ToString("D6");
}
private static byte[] Base32Decode(string base32)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
base32 = base32.ToUpperInvariant().TrimEnd('=');
var bits = new List<byte>();
foreach (var c in base32)
{
var val = alphabet.IndexOf(c);
if (val < 0) continue;
for (var i = 4; i >= 0; i--)
bits.Add((byte)((val >> i) & 1));
}
var bytes = new byte[bits.Count / 8];
for (var i = 0; i < bytes.Length; i++)
{
for (var j = 0; j < 8; j++)
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
}
return bytes;
}
#endregion
}

View File

@@ -0,0 +1,37 @@
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Cleanuparr.Api.Tests;
public sealed class PriorityOrderer : ITestCaseOrderer
{
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
where TTestCase : ITestCase
{
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
foreach (var testCase in testCases)
{
var priority = testCase.TestMethod.Method
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
.FirstOrDefault()
?.GetNamedArgument<int>("Priority") ?? 0;
if (!sortedMethods.TryGetValue(priority, out var list))
{
list = [];
sortedMethods[priority] = list;
}
list.Add(testCase);
}
foreach (var list in sortedMethods.Values)
{
foreach (var testCase in list)
{
yield return testCase;
}
}
}
}

View File

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

View File

@@ -0,0 +1,69 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Api.Auth;
public static class ApiKeyAuthenticationDefaults
{
public const string AuthenticationScheme = "ApiKey";
public const string HeaderName = "X-Api-Key";
public const string QueryParameterName = "apikey";
}
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Try header first, then query string
string? apiKey = null;
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
{
apiKey = headerValue.ToString();
}
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
{
apiKey = queryValue.ToString();
}
if (string.IsNullOrWhiteSpace(apiKey))
{
return AuthenticateResult.NoResult();
}
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
if (user is null)
{
return AuthenticateResult.Fail("Invalid API key");
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("auth_method", "apikey")
};
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
}

View File

@@ -24,6 +24,7 @@
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -29,12 +29,24 @@ public class EventsController : ControllerBase
[FromQuery] string? eventType = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] string? search = null)
[FromQuery] string? search = null,
[FromQuery] string? jobRunId = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
if (page < 1)
{
page = 1;
}
if (pageSize < 1)
{
pageSize = 100;
}
if (pageSize > 1000)
{
pageSize = 1000; // Cap at 1000 for performance
}
var query = _context.Events.AsQueryable();
@@ -62,6 +74,12 @@ public class EventsController : ControllerBase
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply job run ID exact-match filter
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
{
query = query.Where(e => e.JobRunId == jobRunGuid);
}
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
@@ -69,7 +87,10 @@ public class EventsController : ControllerBase
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern)
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
);
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;

View File

@@ -66,7 +66,9 @@ public class ManualEventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern)
);
}

View File

@@ -0,0 +1,189 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StrikesController : ControllerBase
{
private readonly EventsContext _context;
public StrikesController(EventsContext context)
{
_context = context;
}
/// <summary>
/// Gets download items with their strikes (grouped), with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> GetStrikes(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? search = null,
[FromQuery] string? type = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _context.DownloadItems
.Include(d => d.Strikes)
.Where(d => d.Strikes.Any());
// Filter by strike type: only show items that have strikes of this type
if (!string.IsNullOrWhiteSpace(type))
{
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
}
// Apply search filter on title or download hash
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(d =>
EF.Functions.Like(d.Title, pattern) ||
EF.Functions.Like(d.DownloadId, pattern));
}
var totalCount = await query.CountAsync();
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
var items = await query
.OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt))
.Skip(skip)
.Take(pageSize)
.ToListAsync();
var dtos = items.Select(d => new DownloadItemStrikesDto
{
DownloadItemId = d.Id,
DownloadId = d.DownloadId,
Title = d.Title,
TotalStrikes = d.Strikes.Count,
StrikesByType = d.Strikes
.GroupBy(s => s.Type)
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt),
FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt),
IsMarkedForRemoval = d.IsMarkedForRemoval,
IsRemoved = d.IsRemoved,
IsReturning = d.IsReturning,
Strikes = d.Strikes
.OrderByDescending(s => s.CreatedAt)
.Select(s => new StrikeDetailDto
{
Id = s.Id,
Type = s.Type.ToString(),
CreatedAt = s.CreatedAt,
LastDownloadedBytes = s.LastDownloadedBytes,
JobRunId = s.JobRunId,
}).ToList(),
}).ToList();
return Ok(new PaginatedResult<DownloadItemStrikesDto>
{
Items = dtos,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages,
});
}
/// <summary>
/// Gets the most recent individual strikes with download item info (for dashboard)
/// </summary>
[HttpGet("recent")]
public async Task<ActionResult<List<RecentStrikeDto>>> GetRecentStrikes(
[FromQuery] int count = 5)
{
if (count < 1) count = 1;
if (count > 50) count = 50;
var strikes = await _context.Strikes
.Include(s => s.DownloadItem)
.OrderByDescending(s => s.CreatedAt)
.Take(count)
.Select(s => new RecentStrikeDto
{
Id = s.Id,
Type = s.Type.ToString(),
CreatedAt = s.CreatedAt,
DownloadId = s.DownloadItem.DownloadId,
Title = s.DownloadItem.Title,
})
.ToListAsync();
return Ok(strikes);
}
/// <summary>
/// Gets all available strike types
/// </summary>
[HttpGet("types")]
public ActionResult<List<string>> GetStrikeTypes()
{
var types = Enum.GetNames(typeof(StrikeType)).ToList();
return Ok(types);
}
/// <summary>
/// Deletes all strikes for a specific download item
/// </summary>
[HttpDelete("{downloadItemId:guid}")]
public async Task<IActionResult> DeleteStrikesForItem(Guid downloadItemId)
{
var item = await _context.DownloadItems
.Include(d => d.Strikes)
.FirstOrDefaultAsync(d => d.Id == downloadItemId);
if (item == null)
return NotFound();
_context.Strikes.RemoveRange(item.Strikes);
_context.DownloadItems.Remove(item);
await _context.SaveChangesAsync();
return NoContent();
}
}
public class DownloadItemStrikesDto
{
public Guid DownloadItemId { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int TotalStrikes { get; set; }
public Dictionary<string, int> StrikesByType { get; set; } = new();
public DateTime LatestStrikeAt { get; set; }
public DateTime FirstStrikeAt { get; set; }
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
public List<StrikeDetailDto> Strikes { get; set; } = [];
}
public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
}
public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}

View File

@@ -65,9 +65,13 @@ public static class ApiDI
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
// Block non-auth requests until setup is complete
app.UseMiddleware<SetupGuardMiddleware>();
app.UseCors("Any");
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
@@ -108,11 +112,11 @@ public static class ApiDI
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
});
}).AllowAnonymous();
// Map SignalR hubs
app.MapHub<HealthStatusHub>("/api/hubs/health");
app.MapHub<AppHub>("/api/hubs/app");
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
{
@@ -144,7 +148,7 @@ public static class ApiDI
};
return Results.Json(manifest, contentType: "application/manifest+json");
});
}).AllowAnonymous();
return app;
}

View File

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

@@ -87,10 +87,13 @@ public static class MainDI
{
// Add the dynamic HTTP client system - this replaces all the previous static configurations
services.AddDynamicHttpClients();
// Add the dynamic HTTP client provider that uses the new system
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
// Add HTTP client for Plex authentication
services.AddHttpClient("PlexAuth");
return services;
}

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
@@ -26,6 +27,11 @@ public static class ServicesDI
services
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<UsersContext>()
.AddSingleton<IJwtService, JwtService>()
.AddSingleton<IPasswordService, PasswordService>()
.AddSingleton<ITotpService, TotpService>()
.AddScoped<IPlexAuthService, PlexAuthService>()
.AddScoped<IEventPublisher, EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record ChangePasswordRequest
{
[Required]
public required string CurrentPassword { get; init; }
[Required]
[MinLength(8)]
[MaxLength(128)]
public required string NewPassword { get; init; }
}

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record CreateAccountRequest
{
[Required]
[MinLength(3)]
[MaxLength(50)]
public required string Username { get; init; }
[Required]
[MinLength(8)]
[MaxLength(128)]
public required string Password { get; init; }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record Regenerate2faRequest
{
[Required]
public required string Password { get; init; }
[Required]
[StringLength(6, MinimumLength = 6)]
public required string TotpCode { get; init; }
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record TwoFactorRequest
{
[Required]
public required string LoginToken { get; init; }
[Required]
public required string Code { get; init; }
public bool IsRecoveryCode { get; init; }
}

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record VerifyTotpRequest
{
[Required]
[StringLength(6, MinimumLength = 6)]
public required string Code { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record AccountInfoResponse
{
public required string Username { get; init; }
public required bool PlexLinked { get; init; }
public string? PlexUsername { get; init; }
public required bool TwoFactorEnabled { get; init; }
public required string ApiKeyPreview { get; init; }
}

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record PlexPinStatusResponse
{
public required int PinId { get; init; }
public required string AuthUrl { get; init; }
}
public sealed record PlexVerifyResponse
{
public required bool Completed { get; init; }
public TokenResponse? Tokens { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record TokenResponse
{
public required string AccessToken { get; init; }
public required string RefreshToken { get; init; }
public required int ExpiresIn { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record TotpSetupResponse
{
public required string Secret { get; init; }
public required string QrCodeUri { get; init; }
public required List<string> RecoveryCodes { get; init; }
}

View File

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

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

@@ -2,7 +2,7 @@ using System;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;

View File

@@ -3,7 +3,7 @@ using System.IO;
using System.Linq;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -16,7 +16,7 @@ public sealed record CreateDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -24,7 +24,7 @@ public sealed record CreateDownloadClientRequest
public string? UrlBase { get; init; }
public Uri? ExternalUrl { get; init; }
public string? ExternalUrl { get; init; }
public void Validate()
{
@@ -33,10 +33,20 @@ public sealed record CreateDownloadClientRequest
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("External URL is not a valid URL");
}
}
public DownloadClientConfig ToEntity() => new()
@@ -45,10 +55,10 @@ public sealed record CreateDownloadClientRequest
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = ExternalUrl,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
}

View File

@@ -12,7 +12,7 @@ public sealed record TestDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -22,10 +22,15 @@ public sealed record TestDownloadClientRequest
public void Validate()
{
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
}
public DownloadClientConfig ToTestConfig() => new()
@@ -35,7 +40,7 @@ public sealed record TestDownloadClientRequest
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,

View File

@@ -16,7 +16,7 @@ public sealed record UpdateDownloadClientRequest
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Host { get; init; }
public string? Username { get; init; }
@@ -24,7 +24,7 @@ public sealed record UpdateDownloadClientRequest
public string? UrlBase { get; init; }
public Uri? ExternalUrl { get; init; }
public string? ExternalUrl { get; init; }
public void Validate()
{
@@ -33,10 +33,20 @@ public sealed record UpdateDownloadClientRequest
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
if (string.IsNullOrWhiteSpace(Host))
{
throw new ValidationException("Host cannot be empty");
}
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("Host is not a valid URL");
}
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
{
throw new ValidationException("External URL is not a valid URL");
}
}
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
@@ -45,10 +55,10 @@ public sealed record UpdateDownloadClientRequest
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
Username = Username,
Password = Password,
UrlBase = UrlBase,
ExternalUrl = ExternalUrl,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
};
}

View File

@@ -5,10 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;

View File

@@ -30,6 +30,8 @@ public sealed record UpdateGeneralConfigRequest
public List<string> IgnoredDownloads { get; init; } = [];
public ushort StrikeInactivityWindowHours { get; init; } = 24;
public UpdateLoggingConfigRequest Log { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
@@ -44,6 +46,7 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
@@ -61,6 +64,16 @@ public sealed record UpdateGeneralConfigRequest
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
if (config.StrikeInactivityWindowHours is 0)
{
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be greater than 0");
}
if (config.StrikeInactivityWindowHours > 168)
{
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
}
config.Log.Validate();
}

View File

@@ -78,6 +78,21 @@ public sealed class GeneralConfigController : ControllerBase
}
}
[HttpPost("strikes/purge")]
public async Task<IActionResult> PurgeAllStrikes(
[FromServices] EventsContext eventsContext)
{
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
var deletedItems = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
_logger.LogWarning("Purged all strikes: {strikes} strikes, {items} download items removed",
deletedStrikes, deletedItems);
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
}
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
{
if (!wasDryRun || isDryRun)

View File

@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;

View File

@@ -63,7 +63,14 @@ public static class HostExtensions
{
await configContext.Database.MigrateAsync();
}
// Apply users db migrations
await using var usersContext = UsersContext.CreateStaticInstance();
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
{
await usersContext.Database.MigrateAsync();
}
return builder;
}
}

View File

@@ -1,7 +1,12 @@
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.SignalR;
using Quartz;
using Serilog.Context;
@@ -14,48 +19,73 @@ public sealed class GenericJob<T> : IJob
{
private readonly ILogger<GenericJob<T>> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task Execute(IJobExecutionContext context)
{
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
Guid jobRunId = Guid.CreateVersion7();
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobRunStatus? status = null;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
await BroadcastJobStatus(hubContext, jobManagementService, false);
var jobRun = new JobRun { Id = jobRunId, Type = jobType };
eventsContext.JobRuns.Add(jobRun);
await eventsContext.SaveChangesAsync();
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await BroadcastJobStatus(hubContext, jobManagementService, true);
status = JobRunStatus.Completed;
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "{name} failed", typeof(T).Name);
status = JobRunStatus.Failed;
}
finally
{
await using var finalScope = _scopeFactory.CreateAsyncScope();
var eventsContext = finalScope.ServiceProvider.GetRequiredService<EventsContext>();
var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId);
if (jobRun is not null)
{
jobRun.CompletedAt = DateTime.UtcNow;
jobRun.Status = status;
await eventsContext.SaveChangesAsync();
}
}
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
{
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
if (isFinished)
{
jobInfo.Status = "Scheduled";
}
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
}
catch (Exception ex)

View File

@@ -0,0 +1,66 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Middleware;
public class SetupGuardMiddleware
{
private readonly RequestDelegate _next;
private volatile bool _setupCompleted;
public SetupGuardMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Fast path: setup already completed
if (_setupCompleted)
{
await _next(context);
return;
}
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
// Always allow these paths regardless of setup state
if (IsAllowedPath(path))
{
await _next(context);
return;
}
// Check database for setup completion
await using var usersContext = UsersContext.CreateStaticInstance();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is { SetupCompleted: true })
{
_setupCompleted = true;
await _next(context);
return;
}
// Setup not complete - block non-auth requests
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
}
/// <summary>
/// Resets the cached setup state. Call this if the user database is reset.
/// </summary>
public void ResetSetupState()
{
_setupCompleted = false;
}
private static bool IsAllowedPath(string path)
{
return path.StartsWith("/api/auth/")
|| path == "/api/auth"
|| path.StartsWith("/health")
|| !path.StartsWith("/api/");
}
}

View File

@@ -3,6 +3,7 @@ using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Microsoft.AspNetCore.DataProtection;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
@@ -70,12 +71,19 @@ builder.Services.ConfigureHttpJsonOptions(options =>
// Add services to the container
builder.Services
.AddInfrastructure(builder.Configuration)
.AddApiServices();
.AddApiServices()
.AddAuthServices();
// Persist Data Protection keys to the config directory
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
.SetApplicationName("Cleanuparr");
// Add CORS before SignalR
builder.Services.AddCors(options =>
builder.Services.AddCors(options =>
{
options.AddPolicy("Any", policy =>
options.AddPolicy("Any", policy =>
{
policy
// https://github.com/dotnet/aspnetcore/issues/4457#issuecomment-465669576
@@ -146,14 +154,14 @@ app.Init();
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
// Configure health check endpoints as middleware (before auth pipeline) so they don't require authentication
app.UseHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
app.UseHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.HealthCheck;
public sealed record HealthCheckResult
{
public bool IsHealthy { get; set; }
public string? ErrorMessage { get; set; }
public TimeSpan ResponseTime { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum JobRunStatus
{
Completed,
Failed
}

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Domain.Enums;
public enum JobType
{
QueueCleaner,
MalwareBlocker,
DownloadCleaner,
BlacklistSynchronizer,
}

View File

@@ -65,6 +65,9 @@ public class EventPublisherTests : IDisposable
_loggerMock.Object,
_notificationPublisherMock.Object,
_dryRunInterceptorMock.Object);
// Setup JobRunId in context for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
}
public void Dispose()
@@ -339,7 +342,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishQueueItemDeleted_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
// Act
@@ -360,7 +363,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishQueueItemDeleted_SendsNotification()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "abc123");
// Act
@@ -378,7 +381,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishDownloadCleaned_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Cleaned Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Cleaned Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "def456");
// Act
@@ -404,7 +407,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishDownloadCleaned_SendsNotification()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
var ratio = 1.5;
@@ -475,7 +478,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_SavesEventWithContextData()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Category Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Category Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "cat123");
// Act
@@ -493,7 +496,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_WithTag_SavesCorrectMessage()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Tag Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Tag Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "tag123");
// Act
@@ -509,7 +512,7 @@ public class EventPublisherTests : IDisposable
public async Task PublishCategoryChanged_SendsNotification()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test");
ContextProvider.Set(ContextProvider.Keys.Hash, "xyz");
// Act

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class DelugeServiceFixture : IDisposable
{
public Mock<ILogger<DelugeService>> Logger { get; }
public MemoryCache Cache { 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 BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IDelugeClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class DelugeServiceFixture : IDisposable
public DelugeServiceFixture()
{
Logger = new Mock<ILogger<DelugeService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
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 = TestBlocklistProviderFactory.Create();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IDelugeClientWrapper>();
@@ -74,14 +70,13 @@ public class DelugeServiceFixture : IDisposable
return new DelugeService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -112,7 +107,6 @@ public class DelugeServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class QBitServiceFixture : IDisposable
{
public Mock<ILogger<QBitService>> Logger { get; }
public MemoryCache Cache { 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 BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IQBittorrentClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class QBitServiceFixture : IDisposable
public QBitServiceFixture()
{
Logger = new Mock<ILogger<QBitService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
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 = TestBlocklistProviderFactory.Create();
BlocklistProvider =new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IQBittorrentClientWrapper>();
@@ -76,14 +72,13 @@ public class QBitServiceFixture : IDisposable
return new QBitService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -115,7 +110,6 @@ public class QBitServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -470,7 +470,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
});
_fixture.Striker
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata))
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
.ReturnsAsync(false);
// Act
@@ -479,7 +479,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Assert
Assert.False(result.ShouldRemove);
_fixture.Striker.Verify(
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata),
x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()),
Times.Once);
}
@@ -533,7 +533,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
});
_fixture.Striker
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata))
.Setup(x => x.StrikeAndCheckLimit(hash, It.IsAny<string>(), (ushort)3, StrikeType.DownloadingMetadata, It.IsAny<long?>()))
.ReturnsAsync(true); // Strike limit exceeded
// Act
@@ -600,7 +600,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Assert
Assert.False(result.ShouldRemove);
_fixture.Striker.Verify(
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()),
x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()),
Times.Never);
}
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
/// <summary>
/// Test implementation of BlocklistProvider for testing purposes
/// </summary>
public static class TestBlocklistProviderFactory
{
public static BlocklistProvider Create()
{
var logger = new Mock<ILogger<BlocklistProvider>>().Object;
var scopeFactory = new Mock<IServiceScopeFactory>().Object;
var cache = new MemoryCache(new MemoryCacheOptions());
return new BlocklistProvider(logger, scopeFactory, cache);
}
}

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class TransmissionServiceFixture : IDisposable
{
public Mock<ILogger<TransmissionService>> Logger { get; }
public MemoryCache Cache { 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 BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<ITransmissionClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class TransmissionServiceFixture : IDisposable
public TransmissionServiceFixture()
{
Logger = new Mock<ILogger<TransmissionService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
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 = TestBlocklistProviderFactory.Create();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<ITransmissionClientWrapper>();
@@ -74,14 +70,13 @@ public class TransmissionServiceFixture : IDisposable
return new TransmissionService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -112,7 +107,6 @@ public class TransmissionServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -6,9 +6,7 @@ using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Tests.Features.DownloadClient.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -17,14 +15,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class UTorrentServiceFixture : IDisposable
{
public Mock<ILogger<UTorrentService>> Logger { get; }
public MemoryCache Cache { 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 BlocklistProvider BlocklistProvider { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IUTorrentClientWrapper> ClientWrapper { get; }
@@ -32,14 +29,13 @@ public class UTorrentServiceFixture : IDisposable
public UTorrentServiceFixture()
{
Logger = new Mock<ILogger<UTorrentService>>();
Cache = new MemoryCache(new MemoryCacheOptions());
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 = TestBlocklistProviderFactory.Create();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IUTorrentClientWrapper>();
@@ -74,14 +70,13 @@ public class UTorrentServiceFixture : IDisposable
return new UTorrentService(
Logger.Object,
Cache,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
@@ -112,7 +107,6 @@ public class UTorrentServiceFixture : IDisposable
public void Dispose()
{
Cache.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -101,7 +101,8 @@ public class DownloadHunterConsumerTests
InstanceType = InstanceType.Lidarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 999 },
Record = CreateQueueRecord()
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -128,7 +129,8 @@ public class DownloadHunterConsumerTests
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}

View File

@@ -282,7 +282,8 @@ public class DownloadHunterTests : IDisposable
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord()
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}

View File

@@ -105,7 +105,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 456 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.Stalled
DeleteReason = DeleteReason.Stalled,
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -134,7 +135,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 789 },
Record = CreateQueueRecord(),
RemoveFromClient = false,
DeleteReason = DeleteReason.FailedImport
DeleteReason = DeleteReason.FailedImport,
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -162,7 +164,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 111 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.SlowSpeed
DeleteReason = DeleteReason.SlowSpeed,
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
@@ -191,7 +194,8 @@ public class DownloadRemoverConsumerTests
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = true,
DeleteReason = DeleteReason.Stalled
DeleteReason = DeleteReason.Stalled,
JobRunId = Guid.NewGuid()
};
}

View File

@@ -10,12 +10,12 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -48,10 +48,7 @@ public class QueueItemRemoverTests : IDisposable
.Returns(_arrClientMock.Object);
// Create real EventPublisher with mocked dependencies
var eventsContextOptions = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_eventsContext = new EventsContext(eventsContextOptions);
_eventsContext = TestEventsContextFactory.Create();
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
@@ -59,18 +56,10 @@ public class QueueItemRemoverTests : IDisposable
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
// Setup interceptor to skip actual database saves (these tests verify QueueItemRemover, not EventPublisher)
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
.Returns(Task.CompletedTask);
_eventPublisher = new EventPublisher(
_eventsContext,
@@ -84,7 +73,8 @@ public class QueueItemRemoverTests : IDisposable
_busMock.Object,
_memoryCache,
_arrClientFactoryMock.Object,
_eventPublisher
_eventPublisher,
_eventsContext
);
// Clear static RecurringHashes before each test
@@ -455,7 +445,8 @@ public class QueueItemRemoverTests : IDisposable
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
DeleteReason = deleteReason,
JobRunId = Guid.NewGuid()
};
}

View File

@@ -45,6 +45,9 @@ public class JobHandlerFixture : IDisposable
// Setup default behaviors
SetupDefaultBehaviors();
// Setup JobRunId in context for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
}
private void SetupDefaultBehaviors()
@@ -56,6 +59,7 @@ public class JobHandlerFixture : IDisposable
It.IsAny<string>(),
It.IsAny<Domain.Enums.EventSeverity>(),
It.IsAny<object?>(),
It.IsAny<Guid?>(),
It.IsAny<Guid?>()))
.Returns(Task.CompletedTask);
}
@@ -123,6 +127,9 @@ public class JobHandlerFixture : IDisposable
TimeProvider = new FakeTimeProvider();
SetupDefaultBehaviors();
// Setup fresh JobRunId for each test
ContextProvider.SetJobRunId(Guid.NewGuid());
}
public void Dispose()

View File

@@ -0,0 +1,31 @@
using Cleanuparr.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory EventsContext instances for testing.
/// SQLite in-memory supports ExecuteUpdateAsync, ExecuteDeleteAsync, and EF.Functions.Like,
/// unlike the EF Core InMemory provider.
/// </summary>
public static class TestEventsContextFactory
{
/// <summary>
/// Creates a new SQLite in-memory EventsContext with schema initialized
/// </summary>
public static EventsContext Create()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.Options;
var context = new EventsContext(options);
context.Database.EnsureCreated();
return context;
}
}

View File

@@ -65,7 +65,7 @@ public class NotificationPublisherTests
private void SetupDownloadCleanerContext()
{
ContextProvider.Set(ContextProvider.Keys.DownloadName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.ItemName, "Test Download");
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("http://downloadclient.local"));
ContextProvider.Set(ContextProvider.Keys.Hash, "HASH123");
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services;

View File

@@ -7,25 +7,48 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Caching.Memory;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Services;
public class RuleEvaluatorTests
public class RuleEvaluatorTests : IDisposable
{
private readonly EventsContext _context;
public RuleEvaluatorTests()
{
_context = CreateInMemoryEventsContext();
}
public void Dispose()
{
_context.Dispose();
}
private static EventsContext CreateInMemoryEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new EventsContext(options);
}
[Fact]
public async Task ResetStrikes_ShouldRespectMinimumProgressThreshold()
{
// Arrange
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = new StallRule
{
@@ -47,7 +70,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
strikerMock
@@ -64,9 +87,14 @@ public class RuleEvaluatorTests
torrentMock.SetupGet(t => t.CompletionPercentage).Returns(50);
torrentMock.SetupGet(t => t.DownloadedBytes).Returns(() => downloadedBytes);
// Seed cache with initial observation (no reset expected)
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
// Progress below threshold should not reset strikes
downloadedBytes = ByteSize.Parse("1 MB").Bytes;
@@ -84,10 +112,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
@@ -98,7 +126,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Never);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Never);
}
[Fact]
@@ -106,10 +134,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5);
@@ -118,7 +146,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -126,7 +154,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
}
@@ -135,10 +163,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6);
@@ -147,7 +175,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -155,7 +183,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)stallRule.MaxStrikes, StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -163,10 +191,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4);
@@ -175,14 +203,14 @@ public class RuleEvaluatorTests
.Returns(failingRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ThrowsAsync(new InvalidOperationException("boom"));
var torrentMock = CreateTorrentMock();
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateStallRulesAsync(torrentMock.Object));
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -190,10 +218,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
@@ -204,7 +232,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Never);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Never);
}
[Fact]
@@ -212,10 +240,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3);
@@ -224,7 +252,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -232,7 +260,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -240,10 +268,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8);
@@ -252,7 +280,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -260,7 +288,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -268,10 +296,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4);
@@ -295,10 +323,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4);
@@ -307,14 +335,14 @@ public class RuleEvaluatorTests
.Returns(failingRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ThrowsAsync(new InvalidOperationException("slow fail"));
var torrentMock = CreateTorrentMock();
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateSlowRulesAsync(torrentMock.Object));
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -322,10 +350,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Rule",
@@ -339,7 +367,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -348,7 +376,7 @@ public class RuleEvaluatorTests
Assert.True(result.ShouldRemove);
strikerMock.Verify(
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed),
x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()),
Times.Once);
strikerMock.Verify(
x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StrikeType>()),
@@ -360,10 +388,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Both Rule",
@@ -377,7 +405,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -385,7 +413,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.True(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", (ushort)slowRule.MaxStrikes, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -393,10 +421,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
// Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null)
var slowRule = CreateSlowRule(
@@ -415,7 +443,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>()), Times.Never);
strikerMock.Verify(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), It.IsAny<StrikeType>(), It.IsAny<long?>()), Times.Never);
}
[Fact]
@@ -423,10 +451,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Reset",
@@ -455,10 +483,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed No Reset",
@@ -483,10 +511,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Time No Reset",
@@ -511,10 +539,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Speed Strike",
@@ -528,7 +556,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -537,7 +565,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 3, StrikeType.SlowSpeed, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -545,10 +573,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
name: "Time Strike",
@@ -562,7 +590,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -571,7 +599,7 @@ public class RuleEvaluatorTests
var result = await evaluator.EvaluateSlowRulesAsync(torrentMock.Object);
Assert.False(result.ShouldRemove);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime), Times.Once);
strikerMock.Verify(x => x.StrikeAndCheckLimit("hash", "Example Torrent", 5, StrikeType.SlowTime, It.IsAny<long?>()), Times.Once);
}
[Fact]
@@ -579,10 +607,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3);
@@ -591,7 +619,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
long downloadedBytes = ByteSize.Parse("50 MB").Bytes;
@@ -609,12 +637,22 @@ public class RuleEvaluatorTests
[Fact]
public async Task EvaluateStallRulesAsync_WithProgressAndNoMinimumThreshold_ShouldReset()
{
// Arrange
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
// Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes)
var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" };
context.DownloadItems.Add(downloadItem);
await context.SaveChangesAsync();
var initialStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0 };
context.Strikes.Add(initialStrike);
await context.SaveChangesAsync();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null);
@@ -623,23 +661,19 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
strikerMock
.Setup(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled))
.Returns(Task.CompletedTask);
long downloadedBytes = 0;
// Act - Any progress should trigger reset when no minimum is set
long downloadedBytes = ByteSize.Parse("1 KB").Bytes;
var torrentMock = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes);
// Seed cache
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
strikerMock.Verify(x => x.ResetStrikeAsync(It.IsAny<string>(), It.IsAny<string>(), StrikeType.Stalled), Times.Never);
// Any progress should trigger reset when no minimum is set
downloadedBytes = ByteSize.Parse("1 KB").Bytes;
await evaluator.EvaluateStallRulesAsync(torrentMock.Object);
// Assert
strikerMock.Verify(x => x.ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled), Times.Once);
}
@@ -712,10 +746,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingStallRule(It.IsAny<ITorrentItemWrapper>()))
@@ -735,10 +769,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
@@ -747,7 +781,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();
@@ -764,10 +798,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true);
@@ -776,7 +810,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -793,10 +827,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false);
@@ -805,7 +839,7 @@ public class RuleEvaluatorTests
.Returns(stallRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.Stalled, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -822,10 +856,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
ruleManagerMock
.Setup(x => x.GetMatchingSlowRule(It.IsAny<ITorrentItemWrapper>()))
@@ -845,10 +879,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
@@ -857,7 +891,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -874,10 +908,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false);
@@ -886,7 +920,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -903,10 +937,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule(
"Speed Delete True",
@@ -921,7 +955,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowSpeed, It.IsAny<long?>()))
.ReturnsAsync(true);
var torrentMock = CreateTorrentMock();
@@ -939,10 +973,10 @@ public class RuleEvaluatorTests
{
var ruleManagerMock = new Mock<IRuleManager>();
var strikerMock = new Mock<IStriker>();
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var loggerMock = new Mock<ILogger<RuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, memoryCache, loggerMock.Object);
var evaluator = new RuleEvaluator(ruleManagerMock.Object, strikerMock.Object, context, loggerMock.Object);
var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true);
@@ -951,7 +985,7 @@ public class RuleEvaluatorTests
.Returns(slowRule);
strikerMock
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime))
.Setup(x => x.StrikeAndCheckLimit(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ushort>(), StrikeType.SlowTime, It.IsAny<long?>()))
.ReturnsAsync(false);
var torrentMock = CreateTorrentMock();

View File

@@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
@@ -19,18 +18,18 @@ namespace Cleanuparr.Infrastructure.Tests.Services;
public class StrikerTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly EventsContext _strikerContext;
private readonly ILogger<Striker> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Striker _striker;
public StrikerTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_strikerContext = CreateInMemoryEventsContext();
_logger = Substitute.For<ILogger<Striker>>();
// Create EventPublisher with mocked dependencies
var eventsContext = CreateMockEventsContext();
var eventsContext = CreateInMemoryEventsContext();
var hubContext = Substitute.For<IHubContext<AppHub>>();
var hubClients = Substitute.For<IHubClients>();
var clientProxy = Substitute.For<IClientProxy>();
@@ -53,11 +52,14 @@ public class StrikerTests : IDisposable
notificationPublisher,
dryRunInterceptor);
_striker = new Striker(_logger, _cache, _eventPublisher);
_striker = new Striker(_logger, _strikerContext, _eventPublisher);
// Clear static state before each test
Striker.RecurringHashes.Clear();
// Set up required JobRunId for tests
ContextProvider.SetJobRunId(Guid.NewGuid());
// Set up required context for recurring item events and FailedImport strikes
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://localhost:8989"));
@@ -71,7 +73,7 @@ public class StrikerTests : IDisposable
});
}
private static EventsContext CreateMockEventsContext()
private static EventsContext CreateInMemoryEventsContext()
{
var options = new DbContextOptionsBuilder<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
@@ -81,7 +83,7 @@ public class StrikerTests : IDisposable
public void Dispose()
{
_cache.Dispose();
_strikerContext.Dispose();
Striker.RecurringHashes.Clear();
}
@@ -336,4 +338,64 @@ public class StrikerTests : IDisposable
Striker.RecurringHashes.Count.ShouldBe(1);
Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant());
}
[Fact]
public async Task StrikeAndCheckLimit_CreatesNewStrikeRowForEachStrike()
{
// Arrange
const string hash = "strike-rows-test";
const string itemName = "Test Item";
const ushort maxStrikes = 5;
// Act - Strike 3 times
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert - Should have 3 strike rows
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
downloadItem.ShouldNotBeNull();
var strikeCount = await _strikerContext.Strikes
.CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == StrikeType.Stalled);
strikeCount.ShouldBe(3);
}
[Fact]
public async Task StrikeAndCheckLimit_StoresTitleOnDownloadItem()
{
// Arrange
const string hash = "title-test";
const string itemName = "My Movie Title 2024";
const ushort maxStrikes = 3;
// Act
await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled);
// Assert
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
downloadItem.ShouldNotBeNull();
downloadItem.Title.ShouldBe(itemName);
}
[Fact]
public async Task StrikeAndCheckLimit_UpdatesTitleOnDownloadItem_WhenTitleChanges()
{
// Arrange
const string hash = "title-update-test";
const string initialTitle = "Initial Title";
const string updatedTitle = "Updated Title";
const ushort maxStrikes = 5;
// Act - Strike with initial title
await _striker.StrikeAndCheckLimit(hash, initialTitle, maxStrikes, StrikeType.Stalled);
// Strike with updated title
await _striker.StrikeAndCheckLimit(hash, updatedTitle, maxStrikes, StrikeType.Stalled);
// Assert - Title should be updated
var downloadItem = await _strikerContext.DownloadItems.FirstOrDefaultAsync(d => d.DownloadId == hash);
downloadItem.ShouldNotBeNull();
downloadItem.Title.ShouldBe(updatedTitle);
}
}

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Utilities;
using Shouldly;
using Xunit;

View File

@@ -1,80 +0,0 @@
// using Data.Models.Configuration.ContentBlocker;
// using Data.Models.Configuration.DownloadCleaner;
// using Data.Models.Configuration.QueueCleaner;
// using Infrastructure.Interceptors;
// using Infrastructure.Verticals.ContentBlocker;
// using Infrastructure.Verticals.DownloadClient;
// using Infrastructure.Verticals.Files;
// using Infrastructure.Verticals.ItemStriker;
// using Infrastructure.Verticals.Notifications;
// using Microsoft.Extensions.Caching.Memory;
// using Microsoft.Extensions.Logging;
// using Microsoft.Extensions.Options;
// using NSubstitute;
//
// namespace Infrastructure.Tests.Verticals.DownloadClient;
//
// public class DownloadServiceFixture : IDisposable
// {
// public ILogger<DownloadService> Logger { get; set; }
// public IMemoryCache Cache { get; set; }
// public IStriker Striker { get; set; }
//
// public DownloadServiceFixture()
// {
// Logger = Substitute.For<ILogger<DownloadService>>();
// Cache = Substitute.For<IMemoryCache>();
// Striker = Substitute.For<IStriker>();
// }
//
// public TestDownloadService CreateSut(
// QueueCleanerConfig? queueCleanerConfig = null,
// ContentBlockerConfig? contentBlockerConfig = null
// )
// {
// queueCleanerConfig ??= new QueueCleanerConfig
// {
// Enabled = true,
// RunSequentially = true,
// StalledResetStrikesOnProgress = true,
// StalledMaxStrikes = 3
// };
//
// var queueCleanerOptions = Substitute.For<IOptions<QueueCleanerConfig>>();
// queueCleanerOptions.Value.Returns(queueCleanerConfig);
//
// contentBlockerConfig ??= new ContentBlockerConfig
// {
// Enabled = true
// };
//
// var contentBlockerOptions = Substitute.For<IOptions<ContentBlockerConfig>>();
// contentBlockerOptions.Value.Returns(contentBlockerConfig);
//
// var downloadCleanerOptions = Substitute.For<IOptions<DownloadCleanerConfig>>();
// downloadCleanerOptions.Value.Returns(new DownloadCleanerConfig());
//
// var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
// var notifier = Substitute.For<INotificationPublisher>();
// var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// var hardlinkFileService = Substitute.For<IHardLinkFileService>();
//
// return new TestDownloadService(
// Logger,
// queueCleanerOptions,
// contentBlockerOptions,
// downloadCleanerOptions,
// Cache,
// filenameEvaluator,
// Striker,
// notifier,
// dryRunInterceptor,
// hardlinkFileService
// );
// }
//
// public void Dispose()
// {
// // Cleanup if needed
// }
// }

View File

@@ -1,214 +0,0 @@
// using Data.Models.Configuration.DownloadCleaner;
// using Data.Enums;
// using Data.Models.Cache;
// using Infrastructure.Helpers;
// using Infrastructure.Verticals.Context;
// using Infrastructure.Verticals.DownloadClient;
// using NSubstitute;
// using NSubstitute.ClearExtensions;
// using Shouldly;
//
// namespace Infrastructure.Tests.Verticals.DownloadClient;
//
// public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
// {
// private readonly DownloadServiceFixture _fixture;
//
// public DownloadServiceTests(DownloadServiceFixture fixture)
// {
// _fixture = fixture;
// _fixture.Cache.ClearSubstitute();
// _fixture.Striker.ClearSubstitute();
// }
//
// public class ResetStrikesOnProgressTests : DownloadServiceTests
// {
// public ResetStrikesOnProgressTests(DownloadServiceFixture fixture) : base(fixture)
// {
// }
//
// [Fact]
// public void WhenStalledStrikeDisabled_ShouldNotResetStrikes()
// {
// // Arrange
// TestDownloadService sut = _fixture.CreateSut(queueCleanerConfig: new()
// {
// Enabled = true,
// RunSequentially = true,
// StalledResetStrikesOnProgress = false,
// });
//
// // Act
// sut.ResetStalledStrikesOnProgress("test-hash", 100);
//
// // Assert
// _fixture.Cache.ReceivedCalls().ShouldBeEmpty();
// }
//
// [Fact]
// public void WhenProgressMade_ShouldResetStrikes()
// {
// // Arrange
// const string hash = "test-hash";
// StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 100 };
//
// _fixture.Cache.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
// .Returns(x =>
// {
// x[1] = stalledCacheItem;
// return true;
// });
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// sut.ResetStalledStrikesOnProgress(hash, 200);
//
// // Assert
// _fixture.Cache.Received(1).Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
// }
//
// [Fact]
// public void WhenNoProgress_ShouldNotResetStrikes()
// {
// // Arrange
// const string hash = "test-hash";
// StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 200 };
//
// _fixture.Cache
// .TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
// .Returns(x =>
// {
// x[1] = stalledCacheItem;
// return true;
// });
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// sut.ResetStalledStrikesOnProgress(hash, 100);
//
// // Assert
// _fixture.Cache.DidNotReceive().Remove(Arg.Any<object>());
// }
// }
//
// public class StrikeAndCheckLimitTests : DownloadServiceTests
// {
// public StrikeAndCheckLimitTests(DownloadServiceFixture fixture) : base(fixture)
// {
// }
// }
//
// public class ShouldCleanDownloadTests : DownloadServiceTests
// {
// public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture)
// {
// ContextProvider.Set(ContextProvider.Keys.DownloadName, "test-download");
// }
//
// [Fact]
// public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = 1.0,
// MinSeedTime = 1,
// MaxSeedTime = -1
// };
// const double ratio = 1.5;
// TimeSpan seedingTime = TimeSpan.FromHours(2);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeTrue(),
// () => result.Reason.ShouldBe(CleanReason.MaxRatioReached)
// );
// }
//
// [Fact]
// public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = 1.0,
// MinSeedTime = 3,
// MaxSeedTime = -1
// };
// const double ratio = 1.5;
// TimeSpan seedingTime = TimeSpan.FromHours(2);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeFalse(),
// () => result.Reason.ShouldBe(CleanReason.None)
// );
// }
//
// [Fact]
// public void WhenMaxSeedTimeReached_ShouldReturnTrue()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = -1,
// MinSeedTime = 0,
// MaxSeedTime = 1
// };
// const double ratio = 0.5;
// TimeSpan seedingTime = TimeSpan.FromHours(2);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// SeedingCheckResult result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeTrue(),
// () => result.Reason.ShouldBe(CleanReason.MaxSeedTimeReached)
// );
// }
//
// [Fact]
// public void WhenNeitherConditionMet_ShouldReturnFalse()
// {
// // Arrange
// CleanCategory category = new()
// {
// Name = "test",
// MaxRatio = 2.0,
// MinSeedTime = 0,
// MaxSeedTime = 3
// };
// const double ratio = 1.0;
// TimeSpan seedingTime = TimeSpan.FromHours(1);
//
// TestDownloadService sut = _fixture.CreateSut();
//
// // Act
// var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
//
// // Assert
// result.ShouldSatisfyAllConditions(
// () => result.ShouldClean.ShouldBeFalse(),
// () => result.Reason.ShouldBe(CleanReason.None)
// );
// }
// }
// }

View File

@@ -7,12 +7,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />

View File

@@ -14,7 +14,7 @@ public class EventCleanupService : BackgroundService
private readonly ILogger<EventCleanupService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
private readonly int _retentionDays = 30; // Keep events for 30 days
private readonly int _eventRetentionDays = 30; // Keep events for 30 days
public EventCleanupService(ILogger<EventCleanupService> logger, IServiceScopeFactory scopeFactory)
{
@@ -25,7 +25,7 @@ public class EventCleanupService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Event cleanup service started. Interval: {interval}, Retention: {retention} days",
_cleanupInterval, _retentionDays);
_cleanupInterval, _eventRetentionDays);
while (!stoppingToken.IsCancellationRequested)
{
@@ -59,16 +59,19 @@ public class EventCleanupService : BackgroundService
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
await context.Events
var cutoffDate = DateTime.UtcNow.AddDays(-_eventRetentionDays);
await eventsContext.Events
.Where(e => e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
await context.ManualEvents
await eventsContext.ManualEvents
.Where(e => e.Timestamp < cutoffDate)
.Where(e => e.IsResolved)
.ExecuteDeleteAsync();
await CleanupStrikesAsync(eventsContext, dataContext);
}
catch (Exception ex)
{
@@ -76,6 +79,48 @@ public class EventCleanupService : BackgroundService
}
}
private async Task CleanupStrikesAsync(EventsContext eventsContext, DataContext dataContext)
{
var config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
var inactivityWindowHours = config.StrikeInactivityWindowHours;
var cutoffDate = DateTime.UtcNow.AddHours(-inactivityWindowHours);
// Sliding window: find items whose most recent strike is older than the inactivity window.
// As long as a download keeps receiving new strikes, all its strikes are preserved.
var inactiveItemIds = await eventsContext.Strikes
.GroupBy(s => s.DownloadItemId)
.Where(g => g.Max(s => s.CreatedAt) < cutoffDate)
.Select(g => g.Key)
.ToListAsync();
if (inactiveItemIds.Count > 0)
{
var deletedStrikesCount = await eventsContext.Strikes
.Where(s => inactiveItemIds.Contains(s.DownloadItemId))
.ExecuteDeleteAsync();
if (deletedStrikesCount > 0)
{
_logger.LogInformation(
"Cleaned up {count} strikes from {items} inactive items (no new strikes for {hours} hours)",
deletedStrikesCount, inactiveItemIds.Count, inactivityWindowHours);
}
}
// Clean up orphaned DownloadItems (those with no strikes)
int deletedDownloadItemsCount = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
if (deletedDownloadItemsCount > 0)
{
_logger.LogTrace("Cleaned up {count} download items with 0 strikes", deletedDownloadItemsCount);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Event cleanup service stopping...");

View File

@@ -43,7 +43,7 @@ public class EventPublisher : IEventPublisher
/// <summary>
/// Generic method for publishing events to database and SignalR clients
/// </summary>
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null)
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null)
{
AppEvent eventEntity = new()
{
@@ -54,7 +54,13 @@ public class EventPublisher : IEventPublisher
{
Converters = { new JsonStringEnumConverter() }
}) : null,
TrackingId = trackingId
TrackingId = trackingId,
StrikeId = strikeId,
JobRunId = ContextProvider.TryGetJobRunId(),
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
// Save to database with dry run interception
@@ -65,7 +71,7 @@ public class EventPublisher : IEventPublisher
_logger.LogTrace("Published event: {eventType}", eventType);
}
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
{
ManualEvent eventEntity = new()
@@ -76,21 +82,26 @@ public class EventPublisher : IEventPublisher
{
Converters = { new JsonStringEnumConverter() }
}) : null,
JobRunId = ContextProvider.TryGetJobRunId(),
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
// Save to database with dry run interception
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
// Always send to SignalR clients (not affected by dry run)
await NotifyClientsAsync(eventEntity);
_logger.LogTrace("Published manual event: {message}", message);
}
/// <summary>
/// Publishes a strike event with context data and notifications
/// </summary>
public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName)
public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null)
{
// Determine the appropriate EventType based on StrikeType
EventType eventType = strikeType switch
@@ -133,7 +144,11 @@ public class EventPublisher : IEventPublisher
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
data: data);
data: data,
strikeId: strikeId);
// Broadcast strike to SignalR clients for real-time dashboard updates
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName);
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
@@ -145,7 +160,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
@@ -153,7 +168,7 @@ public class EventPublisher : IEventPublisher
EventType.QueueItemDeleted,
$"Deleting item from queue with reason: {deleteReason}",
EventSeverity.Important,
data: new { downloadName, hash, removeFromClient, deleteReason });
data: new { itemName, hash, removeFromClient, deleteReason });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyQueueItemDeleted(removeFromClient, deleteReason);
@@ -165,7 +180,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
@@ -173,7 +188,7 @@ public class EventPublisher : IEventPublisher
EventType.DownloadCleaned,
$"Cleaned item from download client with reason: {reason}",
EventSeverity.Important,
data: new { downloadName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
data: new { itemName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
@@ -185,7 +200,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string itemName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
string hash = ContextProvider.Get<string>(ContextProvider.Keys.Hash);
// Publish the event
@@ -193,7 +208,7 @@ public class EventPublisher : IEventPublisher
EventType.CategoryChanged,
isTag ? $"Tag '{newCategory}' added to download" : $"Category changed from '{oldCategory}' to '{newCategory}'",
EventSeverity.Information,
data: new { downloadName, hash, oldCategory, newCategory, isTag });
data: new { itemName, hash, oldCategory, newCategory, isTag });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
@@ -204,14 +219,10 @@ public class EventPublisher : IEventPublisher
/// </summary>
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
// Publish the event
await PublishManualAsync(
"Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/",
EventSeverity.Important,
data: new { itemName, hash, strikeCount, instanceType, instanceUrl }
data: new { itemName, hash, strikeCount }
);
}
@@ -220,13 +231,10 @@ public class EventPublisher : IEventPublisher
/// </summary>
public async Task PublishSearchNotTriggered(string hash, string itemName)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
await PublishManualAsync(
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
EventSeverity.Warning,
data: new { itemName, hash, instanceType, instanceUrl }
data: new { itemName, hash }
);
}
@@ -267,4 +275,24 @@ public class EventPublisher : IEventPublisher
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
}
}
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName)
{
try
{
var strike = new
{
Id = strikeId ?? Guid.Empty,
Type = strikeType.ToString(),
CreatedAt = DateTime.UtcNow,
DownloadId = hash,
Title = itemName,
};
await _appHubContext.Clients.All.SendAsync("StrikeReceived", strike);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send strike to SignalR clients");
}
}
}

View File

@@ -4,11 +4,11 @@ namespace Cleanuparr.Infrastructure.Events.Interfaces;
public interface IEventPublisher
{
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null);
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null);
Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason);

View File

@@ -0,0 +1,14 @@
using System.Security.Claims;
using Cleanuparr.Persistence.Models.Auth;
namespace Cleanuparr.Infrastructure.Features.Auth;
public interface IJwtService
{
string GenerateAccessToken(User user);
string GenerateLoginToken(Guid userId);
string GenerateRefreshToken();
ClaimsPrincipal? ValidateAccessToken(string token);
Guid? ValidateLoginToken(string token);
byte[] GetOrCreateSigningKey();
}

View File

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

View File

@@ -0,0 +1,28 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
public sealed record PlexPinResult
{
public required int PinId { get; init; }
public required string PinCode { get; init; }
public required string AuthUrl { get; init; }
}
public sealed record PlexPinCheckResult
{
public required bool Completed { get; init; }
public string? AuthToken { get; init; }
}
public sealed record PlexAccountInfo
{
public required string AccountId { get; init; }
public required string Username { get; init; }
public string? Email { get; init; }
}
public interface IPlexAuthService
{
Task<PlexPinResult> RequestPin();
Task<PlexPinCheckResult> CheckPin(int pinId);
Task<PlexAccountInfo> GetAccount(string authToken);
}

View File

@@ -0,0 +1,11 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
public interface ITotpService
{
string GenerateSecret();
string GetQrCodeUri(string secret, string username);
bool ValidateCode(string secret, string code);
List<string> GenerateRecoveryCodes(int count = 10);
string HashRecoveryCode(string code);
bool VerifyRecoveryCode(string code, string hash);
}

View File

@@ -0,0 +1,138 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Cleanuparr.Persistence.Models.Auth;
using Cleanuparr.Shared.Helpers;
using Microsoft.IdentityModel.Tokens;
namespace Cleanuparr.Infrastructure.Features.Auth;
public sealed class JwtService : IJwtService
{
private const string Issuer = "Cleanuparr";
private const string Audience = "Cleanuparr";
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
private readonly byte[] _signingKey;
public JwtService()
{
_signingKey = GetOrCreateSigningKey();
}
public string GenerateAccessToken(User user)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("token_type", "access")
};
return GenerateToken(claims, AccessTokenLifetime);
}
public string GenerateLoginToken(Guid userId)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("token_type", "login")
};
return GenerateToken(claims, LoginTokenLifetime);
}
public string GenerateRefreshToken()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}
public ClaimsPrincipal? ValidateAccessToken(string token)
{
var principal = ValidateToken(token);
if (principal is null) return null;
var tokenType = principal.FindFirst("token_type")?.Value;
return tokenType == "access" ? principal : null;
}
public Guid? ValidateLoginToken(string token)
{
var principal = ValidateToken(token);
if (principal is null) return null;
var tokenType = principal.FindFirst("token_type")?.Value;
if (tokenType != "login") return null;
var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(userIdClaim, out var userId) ? userId : null;
}
public byte[] GetOrCreateSigningKey()
{
var keyPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "jwt-key.bin");
if (File.Exists(keyPath))
{
return File.ReadAllBytes(keyPath);
}
var key = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(key);
var directory = Path.GetDirectoryName(keyPath);
if (directory is not null && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllBytes(keyPath, key);
return key;
}
private string GenerateToken(Claim[] claims, TimeSpan lifetime)
{
var key = new SymmetricSecurityKey(_signingKey);
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.Add(lifetime),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private ClaimsPrincipal? ValidateToken(string token)
{
var key = new SymmetricSecurityKey(_signingKey);
var handler = new JwtSecurityTokenHandler();
try
{
return handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = Issuer,
ValidateAudience = true,
ValidAudience = Audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ClockSkew = TimeSpan.FromSeconds(30)
}, out _);
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,23 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
public sealed class PasswordService : IPasswordService
{
private const int WorkFactor = 12;
public string HashPassword(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
}
public bool VerifyPassword(string password, string hash)
{
try
{
return BCrypt.Net.BCrypt.Verify(password, hash);
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,158 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Auth;
public sealed class PlexAuthService : IPlexAuthService
{
private const string PlexApiBaseUrl = "https://plex.tv/api/v2";
private const string PlexProduct = "Cleanuparr";
private readonly HttpClient _httpClient;
private readonly ILogger<PlexAuthService> _logger;
private readonly string _clientIdentifier;
public PlexAuthService(IHttpClientFactory httpClientFactory, ILogger<PlexAuthService> logger)
{
_httpClient = httpClientFactory.CreateClient("PlexAuth");
_logger = logger;
_clientIdentifier = GetOrCreateClientIdentifier();
}
public async Task<PlexPinResult> RequestPin()
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
AddPlexHeaders(request);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["strong"] = "true"
});
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
if (pin is null)
{
throw new InvalidOperationException("Failed to parse Plex PIN response");
}
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
return new PlexPinResult
{
PinId = pin.Id,
PinCode = pin.Code,
AuthUrl = authUrl
};
}
public async Task<PlexPinCheckResult> CheckPin(int pinId)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/pins/{pinId}");
AddPlexHeaders(request);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return new PlexPinCheckResult { Completed = false };
}
var json = await response.Content.ReadAsStringAsync();
var pin = JsonSerializer.Deserialize<PlexPinResponse>(json);
if (pin is null)
{
throw new InvalidOperationException("Failed to parse Plex PIN response");
}
return new PlexPinCheckResult
{
Completed = !string.IsNullOrEmpty(pin.AuthToken),
AuthToken = pin.AuthToken
};
}
public async Task<PlexAccountInfo> GetAccount(string authToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{PlexApiBaseUrl}/user");
AddPlexHeaders(request);
request.Headers.Add("X-Plex-Token", authToken);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var account = JsonSerializer.Deserialize<PlexAccountResponse>(json);
if (account is null)
{
throw new InvalidOperationException("Failed to parse Plex account response");
}
return new PlexAccountInfo
{
AccountId = account.Id.ToString(),
Username = account.Username,
Email = account.Email
};
}
private void AddPlexHeaders(HttpRequestMessage request)
{
request.Headers.Add("Accept", "application/json");
request.Headers.Add("X-Plex-Client-Identifier", _clientIdentifier);
request.Headers.Add("X-Plex-Product", PlexProduct);
}
private static string GetOrCreateClientIdentifier()
{
var path = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "plex-client-id.txt");
if (File.Exists(path))
{
return File.ReadAllText(path).Trim();
}
var clientId = Guid.NewGuid().ToString("N");
var directory = Path.GetDirectoryName(path);
if (directory is not null && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(path, clientId);
return clientId;
}
// JSON deserialization models
private sealed class PlexPinResponse
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
[JsonPropertyName("authToken")]
public string? AuthToken { get; set; }
}
private sealed class PlexAccountResponse
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("email")]
public string? Email { get; set; }
}
}

View File

@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using OtpNet;
namespace Cleanuparr.Infrastructure.Features.Auth;
public sealed class TotpService : ITotpService
{
private const string Issuer = "Cleanuparr";
public string GenerateSecret()
{
var key = KeyGeneration.GenerateRandomKey(20);
return Base32Encoding.ToString(key);
}
public string GetQrCodeUri(string secret, string username)
{
return $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(username)}?secret={secret}&issuer={Uri.EscapeDataString(Issuer)}&digits=6&period=30";
}
public bool ValidateCode(string secret, string code)
{
if (string.IsNullOrWhiteSpace(code) || code.Length != 6)
{
return false;
}
try
{
var keyBytes = Base32Encoding.ToBytes(secret);
var totp = new Totp(keyBytes);
return totp.VerifyTotp(code, out _, new VerificationWindow(previous: 1, future: 1));
}
catch
{
return false;
}
}
public List<string> GenerateRecoveryCodes(int count = 10)
{
var codes = new List<string>(count);
for (var i = 0; i < count; i++)
{
// Generate 8-character alphanumeric codes in format XXXX-XXXX
var bytes = new byte[5];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
var code = Convert.ToHexString(bytes)[..8].ToUpperInvariant();
codes.Add($"{code[..4]}-{code[4..]}");
}
return codes;
}
public string HashRecoveryCode(string code)
{
// Normalize: remove dashes and uppercase
var normalized = code.Replace("-", "").ToUpperInvariant();
return BCrypt.Net.BCrypt.HashPassword(normalized, 10);
}
public bool VerifyRecoveryCode(string code, string hash)
{
try
{
var normalized = code.Replace("-", "").ToUpperInvariant();
return BCrypt.Net.BCrypt.Verify(normalized, hash);
}
catch
{
return false;
}
}
}

View File

@@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
namespace Cleanuparr.Infrastructure.Features.Context;
@@ -34,12 +34,23 @@ public static class ContextProvider
return Get<T>(key);
}
public const string JobRunIdKey = "JobRunId";
public static Guid GetJobRunId() =>
Get(JobRunIdKey) as Guid? ?? throw new InvalidOperationException("JobRunId not set in context");
public static Guid? TryGetJobRunId() => Get(JobRunIdKey) as Guid?;
public static void SetJobRunId(Guid id) => Set(JobRunIdKey, id);
public static class Keys
{
public const string Version = "version";
public const string DownloadName = "downloadName";
public const string ItemName = "itemName";
public const string Hash = "hash";
public const string DownloadClientUrl = "downloadClientUrl";
public const string DownloadClientType = "downloadClientType";
public const string DownloadClientName = "downloadClientName";
public const string ArrInstanceUrl = "arrInstanceUrl";
}
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
@@ -20,7 +21,6 @@ public partial class DelugeService : DownloadService, IDelugeService
public DelugeService(
ILogger<DelugeService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -32,7 +32,7 @@ public partial class DelugeService : DownloadService, IDelugeService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
@@ -44,7 +44,6 @@ public partial class DelugeService : DownloadService, IDelugeService
// Internal constructor for testing
internal DelugeService(
ILogger<DelugeService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -57,7 +56,7 @@ public partial class DelugeService : DownloadService, IDelugeService
IRuleManager ruleManager,
IDelugeClientWrapper clientWrapper
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)

View File

@@ -72,9 +72,11 @@ public partial class DelugeService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
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);
DelugeContents? contents;
try

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Files;
@@ -11,26 +11,15 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
public class HealthCheckResult
{
public bool IsHealthy { get; set; }
public string? ErrorMessage { get; set; }
public TimeSpan ResponseTime { get; set; }
}
public abstract class DownloadService : IDownloadService
{
protected readonly ILogger<DownloadService> _logger;
protected readonly IMemoryCache _cache;
protected readonly IFilenameEvaluator _filenameEvaluator;
protected readonly IStriker _striker;
protected readonly MemoryCacheEntryOptions _cacheOptions;
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected readonly IHardLinkFileService _hardLinkFileService;
protected readonly IEventPublisher _eventPublisher;
@@ -42,7 +31,6 @@ public abstract class DownloadService : IDownloadService
protected DownloadService(
ILogger<DownloadService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -56,15 +44,12 @@ public abstract class DownloadService : IDownloadService
)
{
_logger = logger;
_cache = cache;
_filenameEvaluator = filenameEvaluator;
_striker = striker;
_dryRunInterceptor = dryRunInterceptor;
_hardLinkFileService = hardLinkFileService;
_eventPublisher = eventPublisher;
_blocklistProvider = blocklistProvider;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
_downloadClientConfig = downloadClientConfig;
_httpClient = httpClientProvider.CreateClient(downloadClientConfig);
_ruleEvaluator = ruleEvaluator;
@@ -124,9 +109,11 @@ public abstract class DownloadService : IDownloadService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
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);
TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds);
SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category);
@@ -220,7 +207,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
TimeSpan minSeedingTime = TimeSpan.FromHours(category.MinSeedTime);
if (category.MinSeedTime > 0 && seedingTime < minSeedingTime)
@@ -246,7 +233,7 @@ public abstract class DownloadService : IDownloadService
return false;
}
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.DownloadName);
string downloadName = ContextProvider.Get<string>(ContextProvider.Keys.ItemName);
TimeSpan maxSeedingTime = TimeSpan.FromHours(category.MaxSeedTime);
if (category.MaxSeedTime > 0 && seedingTime < maxSeedingTime)

View File

@@ -61,7 +61,6 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
private QBitService CreateQBitService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<QBitService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
@@ -75,7 +74,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
// Create the QBitService instance
QBitService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
@@ -86,7 +85,6 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
{
var logger = _serviceProvider.GetRequiredService<ILogger<DelugeService>>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
@@ -99,7 +97,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
// Create the DelugeService instance
DelugeService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
@@ -109,7 +107,6 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
private TransmissionService CreateTransmissionService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<TransmissionService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
@@ -123,7 +120,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
// Create the TransmissionService instance
TransmissionService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);

View File

@@ -1,7 +1,5 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -21,7 +22,6 @@ public partial class QBitService : DownloadService, IQBitService
public QBitService(
ILogger<QBitService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -33,7 +33,7 @@ public partial class QBitService : DownloadService, IQBitService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
@@ -44,7 +44,6 @@ public partial class QBitService : DownloadService, IQBitService
// Internal constructor for testing
internal QBitService(
ILogger<QBitService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -57,7 +56,7 @@ public partial class QBitService : DownloadService, IQBitService
IRuleManager ruleManager,
IQBittorrentClientWrapper clientWrapper
) : base(
logger, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{

View File

@@ -104,9 +104,11 @@ public partial class QBitService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
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);
bool hasHardlinks = false;
bool hasErrors = false;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -39,7 +40,6 @@ public partial class TransmissionService : DownloadService, ITransmissionService
public TransmissionService(
ILogger<TransmissionService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -51,7 +51,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
@@ -70,7 +70,6 @@ public partial class TransmissionService : DownloadService, ITransmissionService
// Internal constructor for testing
internal TransmissionService(
ILogger<TransmissionService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -83,7 +82,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
IRuleManager ruleManager,
ITransmissionClientWrapper clientWrapper
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)

View File

@@ -66,9 +66,11 @@ public partial class TransmissionService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
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);
if (torrent.Info.Files is null || torrent.Info.FileStats is null)
{

View File

@@ -1,4 +1,4 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
@@ -35,7 +35,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
@@ -63,7 +63,6 @@ public partial class UTorrentService : DownloadService, IUTorrentService
// Internal constructor for testing
internal UTorrentService(
ILogger<UTorrentService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
@@ -76,7 +75,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService
IRuleManager ruleManager,
IUTorrentClientWrapper clientWrapper
) : base(
logger, cache,
logger,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)

View File

@@ -62,9 +62,11 @@ public partial class UTorrentService
continue;
}
ContextProvider.Set(ContextProvider.Keys.DownloadName, torrent.Name);
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<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);

View File

@@ -15,4 +15,6 @@ public sealed record DownloadHuntRequest<T>
public required T SearchItem { get; init; }
public required QueueRecord Record { get; init; }
public required Guid JobRunId { get; init; }
}

View File

@@ -17,6 +17,8 @@ public sealed record QueueItemRemoveRequest<T>
public required QueueRecord Record { get; init; }
public required bool RemoveFromClient { get; init; }
public required DeleteReason DeleteReason { get; init; }
public required Guid JobRunId { get; init; }
}

View File

@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
@@ -11,9 +11,11 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -26,13 +28,15 @@ public sealed class QueueItemRemover : IQueueItemRemover
private readonly IMemoryCache _cache;
private readonly IArrClientFactory _arrClientFactory;
private readonly IEventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
public QueueItemRemover(
ILogger<QueueItemRemover> logger,
IBus messageBus,
IMemoryCache cache,
IArrClientFactory arrClientFactory,
IEventPublisher eventPublisher
IEventPublisher eventPublisher,
EventsContext eventsContext
)
{
_logger = logger;
@@ -40,6 +44,7 @@ public sealed class QueueItemRemover : IQueueItemRemover
_cache = cache;
_arrClientFactory = arrClientFactory;
_eventPublisher = eventPublisher;
_eventsContext = eventsContext;
}
public async Task RemoveQueueItemAsync<T>(QueueItemRemoveRequest<T> request)
@@ -50,8 +55,18 @@ public sealed class QueueItemRemover : IQueueItemRemover
var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Mark the download item as removed in the database
await _eventsContext.DownloadItems
.Where(x => EF.Functions.Like(x.DownloadId, request.Record.DownloadId))
.ExecuteUpdateAsync(setter =>
{
setter.SetProperty(x => x.IsRemoved, true);
setter.SetProperty(x => x.IsMarkedForRemoval, false);
});
// Set context for EventPublisher
ContextProvider.Set(ContextProvider.Keys.DownloadName, request.Record.Title);
ContextProvider.SetJobRunId(request.JobRunId);
ContextProvider.Set(ContextProvider.Keys.ItemName, request.Record.Title);
ContextProvider.Set(ContextProvider.Keys.Hash, request.Record.DownloadId);
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalUrl ?? request.Instance.Url);
@@ -75,7 +90,8 @@ public sealed class QueueItemRemover : IQueueItemRemover
InstanceType = request.InstanceType,
Instance = request.Instance,
SearchItem = request.SearchItem,
Record = request.Record
Record = request.Record,
JobRunId = request.JobRunId
});
}
catch (HttpRequestException exception)

View File

@@ -11,7 +11,9 @@ public interface IStriker
/// <param name="itemName">The name of the item</param>
/// <param name="maxStrikes">The maximum number of strikes</param>
/// <param name="strikeType">The strike type</param>
/// <param name="lastDownloadedBytes">Optional: bytes downloaded at time of strike (for progress tracking)</param>
/// <returns>True if the limit has been reached, otherwise false</returns>
Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType);
Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType, long? lastDownloadedBytes = null);
Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType);
}

View File

@@ -1,10 +1,10 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ItemStriker;
@@ -12,47 +12,62 @@ namespace Cleanuparr.Infrastructure.Features.ItemStriker;
public sealed class Striker : IStriker
{
private readonly ILogger<Striker> _logger;
private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly EventsContext _context;
private readonly IEventPublisher _eventPublisher;
public static readonly ConcurrentDictionary<string, string?> RecurringHashes = [];
public Striker(ILogger<Striker> logger, IMemoryCache cache, IEventPublisher eventPublisher)
public Striker(ILogger<Striker> logger, EventsContext context, IEventPublisher eventPublisher)
{
_logger = logger;
_cache = cache;
_context = context;
_eventPublisher = eventPublisher;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}
/// <inheritdoc/>
public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType, long? lastDownloadedBytes = null)
{
if (maxStrikes is 0)
{
_logger.LogTrace("skip striking for {reason} | max strikes is 0 | {name}", strikeType, itemName);
return false;
}
string key = CacheKeys.Strike(strikeType, hash);
if (!_cache.TryGetValue(key, out int strikeCount))
var downloadItem = await GetOrCreateDownloadItemAsync(hash, itemName);
int existingStrikeCount = await _context.Strikes
.CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType);
var strike = new Strike
{
strikeCount = 1;
}
else
DownloadItemId = downloadItem.Id,
JobRunId = ContextProvider.GetJobRunId(),
Type = strikeType,
LastDownloadedBytes = lastDownloadedBytes
};
_context.Strikes.Add(strike);
int strikeCount = existingStrikeCount + 1;
// If item was previously removed and gets a new strike, it has returned
if (downloadItem.IsRemoved)
{
++strikeCount;
downloadItem.IsReturning = true;
downloadItem.IsRemoved = false;
downloadItem.IsMarkedForRemoval = false;
}
// Mark for removal when strike limit reached
if (strikeCount >= maxStrikes)
{
downloadItem.IsMarkedForRemoval = true;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
await _eventPublisher.PublishStrike(strikeType, strikeCount, hash, itemName);
_cache.Set(key, strikeCount, _cacheOptions);
await _eventPublisher.PublishStrike(strikeType, strikeCount, hash, itemName, strike.Id);
if (strikeCount < maxStrikes)
{
return false;
@@ -61,7 +76,7 @@ public sealed class Striker : IStriker
if (strikeCount > maxStrikes)
{
_logger.LogWarning("Blocked item keeps coming back | {name}", itemName);
RecurringHashes.TryAdd(hash.ToLowerInvariant(), null);
await _eventPublisher.PublishRecurringItem(hash, itemName, strikeCount);
}
@@ -71,17 +86,51 @@ public sealed class Striker : IStriker
return true;
}
public Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType)
public async Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType)
{
string key = CacheKeys.Strike(strikeType, hash);
var downloadItem = await _context.DownloadItems
.FirstOrDefaultAsync(d => d.DownloadId == hash);
if (_cache.TryGetValue(key, out int strikeCount) && strikeCount > 0)
if (downloadItem is null)
{
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, strikeCount, itemName);
return;
}
_cache.Remove(key);
var strikesToDelete = await _context.Strikes
.Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType)
.ToListAsync();
return Task.CompletedTask;
if (strikesToDelete.Count > 0)
{
_context.Strikes.RemoveRange(strikesToDelete);
await _context.SaveChangesAsync();
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, strikesToDelete.Count, itemName);
}
}
}
private async Task<DownloadItem> GetOrCreateDownloadItemAsync(string hash, string itemName)
{
var downloadItem = await _context.DownloadItems
.FirstOrDefaultAsync(d => d.DownloadId == hash);
if (downloadItem is not null)
{
if (downloadItem.Title != itemName)
{
downloadItem.Title = itemName;
await _context.SaveChangesAsync();
}
return downloadItem;
}
downloadItem = new DownloadItem
{
DownloadId = hash,
Title = itemName
};
_context.DownloadItems.Add(downloadItem);
await _context.SaveChangesAsync();
return downloadItem;
}
}

View File

@@ -72,6 +72,9 @@ public sealed class DownloadCleaner : GenericHandler
foreach (var downloadService in downloadServices)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.LoginAsync();
@@ -142,9 +145,10 @@ public sealed class DownloadCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
var groups = items
@@ -209,6 +213,9 @@ public sealed class DownloadCleaner : GenericHandler
// Process each client with its own filtered downloads
foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.CreateCategoryAsync(config.UnlinkedTargetCategory);
@@ -222,7 +229,7 @@ public sealed class DownloadCleaner : GenericHandler
downloadService.ClientConfig.Name
);
}
try
{
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory);
@@ -275,6 +282,9 @@ public sealed class DownloadCleaner : GenericHandler
// Process cleaning for each client
foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.CleanDownloadsAsync(downloadsToClean, config.Categories);

View File

@@ -149,7 +149,8 @@ public abstract class GenericHandler : IHandler
Record = record,
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
DeleteReason = deleteReason,
JobRunId = ContextProvider.GetJobRunId()
};
await _messageBus.Publish(removeRequest);
@@ -163,14 +164,16 @@ public abstract class GenericHandler : IHandler
Record = record,
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
DeleteReason = deleteReason,
JobRunId = ContextProvider.GetJobRunId()
};
await _messageBus.Publish(removeRequest);
}
_logger.LogInformation("item marked for removal | {title} | {url}", record.Title, instance.Url);
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important);
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important,
data: new { itemName = record.Title, hash = record.DownloadId });
}
protected SearchItem GetRecordSearchItem(InstanceType type, float version, QueueRecord record, bool isPack = false)

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