Compare commits

..
Author SHA1 Message Date
Flaminel ad8c5f23cf fixed some early returns causing a retry 2026-06-17 15:47:06 +03:00
Flaminel 9eebeed990 fixed log again 2026-06-17 15:46:32 +03:00
Flaminel a79a60a339 fixed ignored downloads bloating with each run 2026-06-17 15:27:32 +03:00
Flaminel d1bd9fddcc retried webhook malware scans while the torrent metadata is not yet available 2026-06-17 15:06:25 +03:00
Flaminel 96823adcc3 reworked webhooks controller to ProblemDetails errors and [controller] route 2026-06-17 12:14:31 +03:00
Flaminel e0e88147aa added more retries 2026-06-17 12:05:45 +03:00
Flaminel f278a0dad0 fixed log 2026-06-17 12:05:35 +03:00
Flaminel f61300b869 fixed docs link 2026-06-16 23:51:01 +03:00
Flaminel 561c05778c showed only the instance id on the arr card instead of the full webhook url 2026-06-16 23:47:11 +03:00
Flaminel 60d273991d used an enum for arr webhook event type instead of hardcoded strings 2026-06-16 23:43:18 +03:00
Flaminel ddb1042ca5 mapped malware-blocker triggerMode help key to its docs anchor 2026-06-16 23:29:07 +03:00
Flaminel 9a31e86ad8 removed unused method 2026-06-16 23:21:56 +03:00
Flaminel 614e97313e passed WebhookScanTarget to ScheduleMalwareBlockerWebhookRetry instead of individual fields 2026-06-16 23:21:56 +03:00
Flaminel a34a3d3c7e made MalwareBlocker webhook retries conditional on the download not being found 2026-06-16 23:21:56 +03:00
Flaminel f9588d89c0 changed some stuff 2026-06-16 23:21:56 +03:00
Flaminel eacd9346a5 generalized MalwareBlockerTriggerMode to JobTriggerMode 2026-06-16 23:21:56 +03:00
Flaminel 0561c64ddf added tests for MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel 304a8e78ee documented MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel e008b64a1d added MalwareBlocker webhook frontend (trigger mode and per-instance URL) 2026-06-16 23:21:56 +03:00
Flaminel 4f7e2d33b4 added webhook-triggered targeted MalwareBlocker scan 2026-06-16 23:21:56 +03:00
Flaminel b1b19e5f29 added content-id filter to arr queue fetch 2026-06-16 23:21:56 +03:00
Flaminel 40ab0e9fad added MalwareBlocker trigger mode config and migration 2026-06-16 23:21:56 +03:00
Flaminel fa1801875e Update frontend packages (#633) 2026-06-16 17:17:52 +03:00
Flaminel c6ef6ad979 Use ProblemDetails handling for the API (#632) 2026-06-16 15:26:56 +03:00
Flaminel 74f11f5beb Migrate from DateTime to DateTimeOffset (#629) 2026-06-15 17:15:06 +03:00
Flaminel c0950537ab Fix width of the recent events card (#630) 2026-06-14 17:49:32 +03:00
Flaminel 7cc079c61b Add new feature badge (#628) 2026-06-14 17:04:59 +03:00
Flaminel 7aa3224f4d Add dead torrent handling (#627) 2026-06-14 02:14:10 +03:00
Flaminel 1cc068c2ab Add Transmission label option for unlinked downloads (#626) 2026-06-13 23:09:26 +03:00
Flaminel 28f22f1085 Fix running orphaned files cleanup for unavailable clients (#624) 2026-06-01 00:36:02 +03:00
Flaminel 084f83efca Add option to remove malware if any file is blocked (#621) 2026-05-30 01:33:51 +03:00
Flaminel 26b76908eb Add min seeders to seeding rules (#620) 2026-05-28 17:23:41 +03:00
Flaminel ffc8a0a39a Fix some backgrounds not respecting the custom color (#619) 2026-05-27 20:08:43 +03:00
Flaminel 8ccd93dc97 Add orphaned files cleanup (#618) 2026-05-27 19:26:39 +03:00
346 changed files with 27918 additions and 3643 deletions

No files matched your search

+14 -3
View File
@@ -27,6 +27,17 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
suite:
- name: api
make-target: up-api
- name: download-clients
make-target: up-dc
name: e2e (${{ matrix.suite.name }})
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -44,7 +55,7 @@ jobs:
- name: Start services
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml up -d --build
run: make ${{ matrix.suite.make-target }}
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
@@ -76,13 +87,13 @@ jobs:
- name: Run E2E tests
working-directory: e2e
run: npx playwright test
run: npx playwright test --project=${{ matrix.suite.name }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
name: e2e-test-results-${{ matrix.suite.name }}
path: |
e2e/playwright-report/
e2e/test-results/
+1
View File
@@ -34,6 +34,7 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Search for **custom format score upgrades** with automatic score tracking.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
@@ -0,0 +1,149 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for POST /api/account/feature-views. Verifies that feature "first seen"
/// timestamps are recorded per user, that recording is idempotent, and that the endpoint
/// requires authentication.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AccountControllerFeatureViewsTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
private static string? _accessToken;
public AccountControllerFeatureViewsTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
if (_accessToken is not null)
{
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
}
[Fact, TestPriority(0)]
public async Task Setup_CreateAccountAndLogin()
{
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "featureadmin",
password = "FeaturePassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "featureadmin",
password = "FeaturePassword123!"
});
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
_accessToken.ShouldNotBeNullOrEmpty();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
[Fact, TestPriority(1)]
public async Task RecordFeatureViews_NewIds_RecordsTimestampsAndReturnsMapWithAnchor()
{
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a", "feature-b" }
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
body.ShouldNotBeNull();
body.CreatedAt.ShouldNotBe(default);
body.Views.ShouldContainKey("feature-a");
body.Views.ShouldContainKey("feature-b");
body.Views["feature-a"].Offset.ShouldBe(TimeSpan.Zero);
}
[Fact, TestPriority(2)]
public async Task RecordFeatureViews_DuplicateId_IsIdempotentAndKeepsOriginalTimestamp()
{
var firstResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a" }
});
firstResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var firstBody = await firstResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
var originalTimestamp = firstBody!.Views["feature-a"];
var secondResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a" }
});
secondResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var secondBody = await secondResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
secondBody!.Views["feature-a"].ShouldBe(originalTimestamp);
}
[Fact, TestPriority(3)]
public async Task RecordFeatureViews_WhenUnauthenticated_ReturnsUnauthorized()
{
var unauthClient = _factory.CreateClient();
var response = await unauthClient.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a" }
});
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact, TestPriority(4)]
public async Task RecordFeatureViews_TooManyIds_ReturnsBadRequest()
{
var tooMany = Enumerable.Range(0, 101).Select(i => $"feature-{i}").ToArray();
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = tooMany
});
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
[Fact, TestPriority(5)]
public async Task RecordFeatureViews_OverLengthId_IsSkipped()
{
var overLengthId = new string('x', 65);
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-ok", overLengthId }
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
body.ShouldNotBeNull();
body.Views.ShouldContainKey("feature-ok");
body.Views.ShouldNotContainKey(overLengthId);
}
private sealed record FeatureViewsResponseDto
{
public DateTimeOffset CreatedAt { get; init; }
public Dictionary<string, DateTimeOffset> Views { get; init; } = new();
}
}
@@ -87,7 +87,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
body.GetProperty("detail").GetString().ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]
@@ -67,9 +67,11 @@ public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.Oid
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(3)]
@@ -0,0 +1,106 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class DeadTorrentConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly DeadTorrentConfigController _controller;
public DeadTorrentConfigControllerTests()
{
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
_controller = new DeadTorrentConfigController(logger, _dataContext);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static DeadTorrentConfigRequest ValidRequest(
bool enabled = true,
string targetCategory = "cleanuparr-dead",
bool useTag = false,
ushort maxStrikes = 3,
List<string>? categories = null)
=> new()
{
Enabled = enabled,
TargetCategory = targetCategory,
UseTag = useTag,
MaxStrikes = maxStrikes,
Categories = categories ?? ["movies"],
};
[Fact]
public async Task Update_ValidRequest_PersistsConfig()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 5, categories: ["movies", "tv"]));
result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.DeadTorrentConfigs.AsNoTracking().SingleAsync(d => d.DownloadClientConfigId == client.Id);
saved.Enabled.ShouldBeTrue();
saved.MaxStrikes.ShouldBe((ushort)5);
saved.Categories.ShouldBe(new List<string> { "movies", "tv" });
}
[Fact]
public async Task Update_ThenGet_RoundTrips()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(useTag: true, maxStrikes: 4));
var result = await _controller.GetDeadTorrentConfig(client.Id);
var ok = result.ShouldBeOfType<OkObjectResult>();
var config = ok.Value.ShouldBeOfType<DeadTorrentConfigResponse>();
config.UseTag.ShouldBeTrue();
config.MaxStrikes.ShouldBe((ushort)4);
}
[Fact]
public async Task Update_StrikesBelowMinimum_ThrowsValidationException()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 2)));
}
[Fact]
public async Task Update_EnabledForRTorrent_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.rTorrent, "Test rTorrent");
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Update_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
}
@@ -1,10 +1,13 @@
using System.Text.Json;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -22,6 +25,7 @@ public class SeedingRulesControllerTests : IDisposable
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<SeedingRulesController>>();
_controller = new SeedingRulesController(logger, _dataContext);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
@@ -40,6 +44,7 @@ public class SeedingRulesControllerTests : IDisposable
double maxRatio = 2.0,
double minSeedTime = 0,
double maxSeedTime = -1,
int minSeeders = 0,
bool deleteSourceFiles = true)
{
return new SeedingRuleRequest
@@ -54,22 +59,22 @@ public class SeedingRulesControllerTests : IDisposable
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = deleteSourceFiles,
};
}
private static JsonElement GetJsonBody(IActionResult result)
private static List<SeedingRuleResponse> GetRulesFromOk(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
return JsonDocument.Parse(json).RootElement;
IEnumerable<SeedingRuleResponse> rules = okResult.Value.ShouldBeAssignableTo<IEnumerable<SeedingRuleResponse>>()!;
return rules.ToList();
}
private static JsonElement GetCreatedJsonBody(IActionResult result)
private static T GetCreatedRule<T>(IActionResult result) where T : ISeedingRule
{
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
var json = JsonSerializer.Serialize(createdResult.Value);
return JsonDocument.Parse(json).RootElement;
return createdResult.Value.ShouldBeOfType<T>();
}
// ──────────────────────────────────────────────────────────────────────
@@ -83,10 +88,7 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array.GetArrayLength().ShouldBe(0);
GetRulesFromOk(result).ShouldBeEmpty();
}
[Fact]
@@ -99,20 +101,18 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array.GetArrayLength().ShouldBe(3);
array[0].GetProperty("name").GetString().ShouldBe("Rule A");
array[1].GetProperty("name").GetString().ShouldBe("Rule B");
array[2].GetProperty("name").GetString().ShouldBe("Rule C");
List<SeedingRuleResponse> rules = GetRulesFromOk(result);
rules.Count.ShouldBe(3);
rules[0].Name.ShouldBe("Rule A");
rules[1].Name.ShouldBe("Rule B");
rules[2].Name.ShouldBe("Rule C");
}
[Fact]
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.GetSeedingRules(Guid.NewGuid());
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -124,12 +124,21 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var rule = JsonDocument.Parse(json).RootElement[0];
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(2);
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(1);
rule.GetProperty("tagsAll")[0].GetString().ShouldBe("required");
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
rule.TagsAny.ShouldBe(new List<string> { "hd", "private" });
rule.TagsAll.ShouldBe(new List<string> { "required" });
}
[Fact]
public async Task GetSeedingRules_ReturnsMinSeeders()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, minSeeders: 5);
var result = await _controller.GetSeedingRules(client.Id);
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
rule.MinSeeders.ShouldBe(5);
}
[Fact]
@@ -140,11 +149,9 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.GetSeedingRules(client.Id);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var rule = JsonDocument.Parse(json).RootElement[0];
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(0);
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(0);
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
rule.TagsAny.ShouldBeEmpty();
rule.TagsAll.ShouldBeEmpty();
}
// ──────────────────────────────────────────────────────────────────────
@@ -162,9 +169,9 @@ public class SeedingRulesControllerTests : IDisposable
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
createdResult.StatusCode.ShouldBe(201);
var body = GetCreatedJsonBody(result);
body.GetProperty("Name").GetString().ShouldBe("Movies Rule");
body.GetProperty("Categories").GetArrayLength().ShouldBe(2);
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
rule.Name.ShouldBe("Movies Rule");
rule.Categories.ShouldBe(new List<string> { "movies", "films" });
}
[Fact]
@@ -175,8 +182,18 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.CreateSeedingRule(client.Id, request);
var body = GetCreatedJsonBody(result);
body.GetProperty("Priority").GetInt32().ShouldBe(1);
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(1);
}
[Fact]
public async Task CreateSeedingRule_SetsMinSeeders()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(minSeeders: 5);
var result = await _controller.CreateSeedingRule(client.Id, request);
GetCreatedRule<QBitSeedingRule>(result).MinSeeders.ShouldBe(5);
}
[Fact]
@@ -189,8 +206,7 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.CreateSeedingRule(client.Id, request);
var body = GetCreatedJsonBody(result);
body.GetProperty("Priority").GetInt32().ShouldBe(2);
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(2);
}
[Fact]
@@ -202,7 +218,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = CreateValidRequest(priority: 1);
var result = await _controller.CreateSeedingRule(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -211,7 +227,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = CreateValidRequest();
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -220,10 +236,7 @@ public class SeedingRulesControllerTests : IDisposable
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(categories: []);
var result = await _controller.CreateSeedingRule(client.Id, request);
// Validate() throws ValidationException → caught → BadRequest
result.ShouldBeOfType<BadRequestObjectResult>();
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
}
[Fact]
@@ -235,11 +248,8 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.CreateSeedingRule(client.Id, request);
var body = GetCreatedJsonBody(result);
var patterns = body.GetProperty("TrackerPatterns");
patterns.GetArrayLength().ShouldBe(2);
patterns[0].GetString().ShouldBe("valid.com");
patterns[1].GetString().ShouldBe("trimmed.com");
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
rule.TrackerPatterns.ShouldBe(new List<string> { "valid.com", "trimmed.com" });
}
[Fact]
@@ -251,8 +261,7 @@ public class SeedingRulesControllerTests : IDisposable
var result = await _controller.CreateSeedingRule(client.Id, request);
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
createdResult.Value.ShouldBeOfType<TransmissionSeedingRule>();
GetCreatedRule<TransmissionSeedingRule>(result).TagsAny.ShouldBe(new List<string> { "tag1" });
}
// ──────────────────────────────────────────────────────────────────────
@@ -306,13 +315,28 @@ public class SeedingRulesControllerTests : IDisposable
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
}
[Fact]
public async Task UpdateSeedingRule_UpdatesMinSeeders()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(minSeeders: 5);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.MinSeeders.ShouldBe(5);
}
[Fact]
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
{
var request = CreateValidRequest();
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -324,8 +348,7 @@ public class SeedingRulesControllerTests : IDisposable
// Both maxRatio and maxSeedTime negative → validation failure
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
}
// ──────────────────────────────────────────────────────────────────────
@@ -357,18 +380,14 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
await _controller.ReorderSeedingRules(client.Id, request);
// Verify via GET
var getResult = await _controller.GetSeedingRules(client.Id);
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
List<SeedingRuleResponse> rules = GetRulesFromOk(await _controller.GetSeedingRules(client.Id));
array[0].GetProperty("name").GetString().ShouldBe("C");
array[0].GetProperty("priority").GetInt32().ShouldBe(1);
array[1].GetProperty("name").GetString().ShouldBe("B");
array[1].GetProperty("priority").GetInt32().ShouldBe(2);
array[2].GetProperty("name").GetString().ShouldBe("A");
array[2].GetProperty("priority").GetInt32().ShouldBe(3);
rules[0].Name.ShouldBe("C");
rules[0].Priority.ShouldBe(1);
rules[1].Name.ShouldBe("B");
rules[1].Priority.ShouldBe(2);
rules[2].Name.ShouldBe("A");
rules[2].Priority.ShouldBe(3);
}
[Fact]
@@ -377,7 +396,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
@@ -390,7 +409,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -404,7 +423,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
@@ -417,7 +436,7 @@ public class SeedingRulesControllerTests : IDisposable
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<BadRequestObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
// ──────────────────────────────────────────────────────────────────────
@@ -442,18 +461,13 @@ public class SeedingRulesControllerTests : IDisposable
await _controller.DeleteSeedingRule(rule.Id);
// Verify rule no longer exists
var getResult = await _controller.GetSeedingRules(client.Id);
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
var array = JsonDocument.Parse(json).RootElement;
array.GetArrayLength().ShouldBe(0);
GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).ShouldBeEmpty();
}
[Fact]
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
{
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
result.ShouldBeOfType<NotFoundObjectResult>();
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
}
@@ -119,7 +119,8 @@ public static class SeedingRulesTestDataFactory
List<string>? tagsAll = null,
double maxRatio = 2.0,
double minSeedTime = 0,
double maxSeedTime = -1)
double maxSeedTime = -1,
int minSeeders = 0)
{
var rule = new QBitSeedingRule
{
@@ -135,6 +136,7 @@ public static class SeedingRulesTestDataFactory
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = true,
};
@@ -150,7 +152,8 @@ public static class SeedingRulesTestDataFactory
int priority = 1,
List<string>? categories = null,
double maxRatio = 2.0,
double maxSeedTime = -1)
double maxSeedTime = -1,
int minSeeders = 0)
{
var rule = new DelugeSeedingRule
{
@@ -164,6 +167,7 @@ public static class SeedingRulesTestDataFactory
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = true,
};
@@ -179,7 +183,8 @@ public static class SeedingRulesTestDataFactory
int priority = 1,
List<string>? categories = null,
double maxRatio = 2.0,
double maxSeedTime = -1)
double maxSeedTime = -1,
int minSeeders = 0)
{
var rule = new TransmissionSeedingRule
{
@@ -195,6 +200,7 @@ public static class SeedingRulesTestDataFactory
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = true,
};
@@ -0,0 +1,164 @@
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Api.Features.Webhooks.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Webhooks;
public class WebhooksControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagement;
private readonly WebhooksController _controller;
private Guid _sonarrInstanceId;
private Guid _lidarrInstanceId;
public WebhooksControllerTests()
{
_dataContext = CreateDataContext();
_jobManagement = Substitute.For<IJobManagementService>();
var logger = Substitute.For<ILogger<WebhooksController>>();
_controller = new WebhooksController(logger, _dataContext, _jobManagement);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private DataContext CreateDataContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>().UseSqlite(connection).Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
var sonarrInstance = new ArrInstance { Enabled = true, Name = "Sonarr", Url = new Uri("http://sonarr:8989"), ApiKey = "key" };
var lidarrInstance = new ArrInstance { Enabled = true, Name = "Lidarr", Url = new Uri("http://lidarr:8686"), ApiKey = "key" };
_sonarrInstanceId = sonarrInstance.Id;
_lidarrInstanceId = lidarrInstance.Id;
context.ArrConfigs.AddRange(
new ArrConfig { Type = InstanceType.Sonarr, Instances = [sonarrInstance] },
new ArrConfig { Type = InstanceType.Lidarr, Instances = [lidarrInstance] }
);
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Enabled = true,
TriggerMode = JobTriggerMode.Both,
IgnoredDownloads = [],
});
context.SaveChanges();
return context;
}
private void SetConfig(bool enabled, JobTriggerMode mode)
{
var config = _dataContext.ContentBlockerConfigs.First();
config.Enabled = enabled;
config.TriggerMode = mode;
_dataContext.SaveChanges();
}
private static ArrWebhookPayload GrabPayload(string? downloadId = "HASH123", long seriesId = 42) => new()
{
EventType = "Grab",
DownloadId = downloadId,
Series = new ArrWebhookContent { Id = seriesId },
};
[Fact]
public async Task TestEvent_ReturnsOk_AndDoesNotSchedule()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, new ArrWebhookPayload { EventType = "Test" });
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task ValidGrab_SchedulesTargetedScan()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.Received(1)
.TriggerMalwareBlockerWebhook(_sonarrInstanceId, "HASH123", 42, InstanceType.Sonarr);
}
[Fact]
public async Task UnknownInstance_ReturnsNotFound()
{
var result = await _controller.TriggerMalwareBlocker(Guid.NewGuid(), GrabPayload());
var notFound = result.ShouldBeOfType<ObjectResult>();
notFound.StatusCode.ShouldBe(StatusCodes.Status404NotFound);
notFound.Value.ShouldBeOfType<ProblemDetails>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task NonSonarrRadarrInstance_ReturnsUnprocessable()
{
var result = await _controller.TriggerMalwareBlocker(_lidarrInstanceId, GrabPayload());
var unprocessable = result.ShouldBeOfType<ObjectResult>();
unprocessable.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity);
unprocessable.Value.ShouldBeOfType<ProblemDetails>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task Disabled_ReturnsOk_AndDoesNotSchedule()
{
SetConfig(enabled: false, JobTriggerMode.Both);
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task ScheduleOnlyMode_ReturnsOk_AndDoesNotSchedule()
{
SetConfig(enabled: true, JobTriggerMode.Schedule);
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task EmptyDownloadId_ReturnsOk_AndDoesNotSchedule()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload(downloadId: null));
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
}
@@ -0,0 +1,108 @@
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Api.Middleware;
using Cleanuparr.Domain.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Middleware;
public class GlobalExceptionHandlerTests
{
private static readonly ProblemDetailsFactory ProblemDetailsFactory = BuildProblemDetailsFactory();
private static ProblemDetailsFactory BuildProblemDetailsFactory()
{
ServiceCollection services = new();
services.AddLogging();
services.AddControllers();
services.AddCleanuparrProblemDetails();
return services.BuildServiceProvider().GetRequiredService<ProblemDetailsFactory>();
}
private static async Task<(bool handled, HttpContext context, ProblemDetails problemDetails)> Handle(Exception exception)
{
IProblemDetailsService problemDetailsService = Substitute.For<IProblemDetailsService>();
problemDetailsService
.TryWriteAsync(Arg.Any<ProblemDetailsContext>())
.Returns(callInfo => ValueTask.FromResult(true));
DefaultHttpContext context = new();
GlobalExceptionHandler handler = new(problemDetailsService, ProblemDetailsFactory, NullLogger<GlobalExceptionHandler>.Instance);
bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None);
ProblemDetailsContext captured = (ProblemDetailsContext)problemDetailsService
.ReceivedCalls()
.Single()
.GetArguments()[0]!;
return (handled, context, captured.ProblemDetails);
}
[Fact]
public async Task ValidationException_MapsTo400_WithMessageAsDetail()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new ValidationException("Name is required"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Title.ShouldBe("Validation failed");
problemDetails.Detail.ShouldBe("Name is required");
problemDetails.Type.ShouldNotBeNullOrEmpty();
problemDetails.Extensions.ShouldContainKey("traceId");
}
[Fact]
public async Task NotificationTestException_MapsTo400()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new NotificationTestException("Test failed: connection refused"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Title.ShouldBe("Notification test failed");
problemDetails.Detail.ShouldBe("Test failed: connection refused");
}
[Fact]
public async Task RateLimitException_MapsTo429_WithRetryAfterExtensionAndHeader()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Account is locked", 30));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Status.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Title.ShouldBe("Too many requests");
problemDetails.Extensions["retryAfterSeconds"].ShouldBe(30);
context.Response.Headers.RetryAfter.ToString().ShouldBe("30");
}
[Fact]
public async Task RateLimitException_WithZeroRetry_MapsTo429_WithoutRetryAfter()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Too many pending OIDC flows", 0));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Extensions.ShouldNotContainKey("retryAfterSeconds");
context.Response.Headers.RetryAfter.ToString().ShouldBeEmpty();
}
[Fact]
public async Task UnknownException_MapsTo500_WithGenericDetail_AndDoesNotLeakMessage()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new InvalidOperationException("internal connection string leaked"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError);
problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError);
problemDetails.Detail.ShouldBe("An unexpected error occurred");
problemDetails.Detail.ShouldNotContain("connection string");
}
}
@@ -0,0 +1,35 @@
using Cleanuparr.Api.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Api.Tests.TestHelpers;
/// <summary>
/// Attaches a minimal MVC <see cref="ControllerContext"/> (with a real <see cref="ProblemDetailsFactory"/>
/// and <see cref="HttpContext"/>) to a directly-instantiated controller so that
/// <c>this.ProblemResult(...)</c> can build problem-details responses in unit tests.
/// </summary>
public static class ControllerTestContext
{
private static readonly IServiceProvider Services = BuildServices();
private static IServiceProvider BuildServices()
{
ServiceCollection services = new();
services.AddLogging();
services.AddControllers();
services.AddCleanuparrProblemDetails();
return services.BuildServiceProvider();
}
public static void Attach(ControllerBase controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { RequestServices = Services },
};
controller.ProblemDetailsFactory = Services.GetRequiredService<ProblemDetailsFactory>();
}
}
@@ -29,8 +29,8 @@ public class EventsController : ControllerBase
[FromQuery] int pageSize = 50,
[FromQuery] string? severity = null,
[FromQuery] string? eventType = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] DateTimeOffset? fromDate = null,
[FromQuery] DateTimeOffset? toDate = null,
[FromQuery] string? search = null,
[FromQuery] string? jobRunId = null)
{
@@ -155,7 +155,7 @@ public class EventsController : ControllerBase
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
await _context.Events
.Where(e => e.Timestamp < cutoffDate)
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Infrastructure.Health;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,17 +13,13 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class HealthCheckController : ControllerBase
{
private readonly ILogger<HealthCheckController> _logger;
private readonly IHealthCheckService _healthCheckService;
/// <summary>
/// Initializes a new instance of the <see cref="HealthCheckController"/> class
/// </summary>
public HealthCheckController(
ILogger<HealthCheckController> logger,
IHealthCheckService healthCheckService)
public HealthCheckController(IHealthCheckService healthCheckService)
{
_logger = logger;
_healthCheckService = healthCheckService;
}
@@ -32,16 +29,8 @@ public class HealthCheckController : ControllerBase
[HttpGet]
public IActionResult GetAllHealth()
{
try
{
var healthStatuses = _healthCheckService.GetAllClientHealth();
return Ok(healthStatuses);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving client health statuses");
return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
}
var healthStatuses = _healthCheckService.GetAllClientHealth();
return Ok(healthStatuses);
}
/// <summary>
@@ -50,21 +39,13 @@ public class HealthCheckController : ControllerBase
[HttpGet("{id:guid}")]
public IActionResult GetClientHealth(Guid id)
{
try
var healthStatus = _healthCheckService.GetClientHealth(id);
if (healthStatus == null)
{
var healthStatus = _healthCheckService.GetClientHealth(id);
if (healthStatus == null)
{
return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
}
return this.ProblemResult(StatusCodes.Status404NotFound, $"Health status for client with ID '{id}' not found");
}
return Ok(healthStatus);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving health status for client {id}", id);
return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
}
return Ok(healthStatus);
}
/// <summary>
@@ -73,16 +54,8 @@ public class HealthCheckController : ControllerBase
[HttpPost("check")]
public async Task<IActionResult> CheckAllHealth()
{
try
{
var results = await _healthCheckService.CheckAllClientsHealthAsync();
return Ok(results);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for all clients");
return StatusCode(500, new { Error = "An error occurred while checking client health" });
}
var results = await _healthCheckService.CheckAllClientsHealthAsync();
return Ok(results);
}
/// <summary>
@@ -91,15 +64,7 @@ public class HealthCheckController : ControllerBase
[HttpPost("check/{id:guid}")]
public async Task<IActionResult> CheckClientHealth(Guid id)
{
try
{
var result = await _healthCheckService.CheckClientHealthAsync(id);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for client {id}", id);
return StatusCode(500, new { Error = "An error occurred while checking client health" });
}
var result = await _healthCheckService.CheckClientHealthAsync(id);
return Ok(result);
}
}
@@ -36,13 +36,13 @@ public class HealthController : ControllerBase
registration => registration.Tags.Contains("liveness"));
return result.Status == HealthStatus.Healthy
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
? Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTimeOffset.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTimeOffset.UtcNow });
}
}
@@ -62,13 +62,13 @@ public class HealthController : ControllerBase
if (result.Status == HealthStatus.Healthy)
{
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
return Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow });
}
// For readiness, we consider degraded as not ready
return StatusCode(503, new {
status = "not_ready",
timestamp = DateTime.UtcNow,
timestamp = DateTimeOffset.UtcNow,
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
.ToDictionary(e => e.Key, e => new {
status = e.Value.Status.ToString().ToLowerInvariant(),
@@ -79,7 +79,7 @@ public class HealthController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Readiness check failed");
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTimeOffset.UtcNow });
}
}
@@ -97,7 +97,7 @@ public class HealthController : ControllerBase
var response = new
{
status = result.Status.ToString().ToLowerInvariant(),
timestamp = DateTime.UtcNow,
timestamp = DateTimeOffset.UtcNow,
totalDuration = result.TotalDuration.TotalMilliseconds,
entries = result.Entries.ToDictionary(
e => e.Key,
@@ -122,7 +122,7 @@ public class HealthController : ControllerBase
return StatusCode(503, new {
status = "unhealthy",
error = "Detailed health check failed",
timestamp = DateTime.UtcNow
timestamp = DateTimeOffset.UtcNow
});
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
@@ -13,47 +14,29 @@ namespace Cleanuparr.Api.Controllers;
public class JobsController : ControllerBase
{
private readonly IJobManagementService _jobManagementService;
private readonly ILogger<JobsController> _logger;
public JobsController(IJobManagementService jobManagementService, ILogger<JobsController> logger)
public JobsController(IJobManagementService jobManagementService)
{
_jobManagementService = jobManagementService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAllJobs()
{
try
{
var result = await _jobManagementService.GetAllJobs();
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all jobs");
return StatusCode(500, "An error occurred while retrieving jobs");
}
var result = await _jobManagementService.GetAllJobs();
return Ok(result);
}
[HttpGet("{jobType}")]
public async Task<IActionResult> GetJob(JobType jobType)
{
try
var jobInfo = await _jobManagementService.GetJob(jobType);
if (jobInfo.Status == "Not Found")
{
var jobInfo = await _jobManagementService.GetJob(jobType);
if (jobInfo.Status == "Not Found")
{
return NotFound($"Job '{jobType}' not found");
}
return Ok(jobInfo);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting job {jobType}", jobType);
return StatusCode(500, $"An error occurred while retrieving job '{jobType}'");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Job '{jobType}' not found");
}
return Ok(jobInfo);
}
[HttpPost("{jobType}/start")]
@@ -61,27 +44,19 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually controlled");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
}
try
// Get the schedule from the request body if provided
JobSchedule jobSchedule = scheduleRequest.Schedule;
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
if (!result)
{
// Get the schedule from the request body if provided
JobSchedule jobSchedule = scheduleRequest.Schedule;
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
if (!result)
{
return BadRequest($"Failed to start job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' started successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting job {jobType}", jobType);
return StatusCode(500, $"An error occurred while starting job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to start job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' started successfully" });
}
[HttpPost("{jobType}/trigger")]
@@ -89,24 +64,16 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually triggered");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually triggered");
}
try
var result = await _jobManagementService.TriggerJobOnce(jobType);
if (!result)
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
if (!result)
{
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to trigger job '{jobType}' - job may not exist or be configured");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
}
[HttpPut("{jobType}/schedule")]
@@ -114,28 +81,20 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job schedule cannot be manually modified");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job schedule cannot be manually modified");
}
if (scheduleRequest?.Schedule == null)
{
return BadRequest("Schedule is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
}
try
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
if (!result)
{
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
if (!result)
{
return BadRequest($"Failed to update schedule for job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job {jobType} schedule", jobType);
return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to update schedule for job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
}
}
@@ -28,8 +28,8 @@ public class ManualEventsController : ControllerBase
[FromQuery] int pageSize = 50,
[FromQuery] bool? isResolved = null,
[FromQuery] string? severity = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] DateTimeOffset? fromDate = null,
[FromQuery] DateTimeOffset? toDate = null,
[FromQuery] string? search = null)
{
// Validate pagination parameters
@@ -182,7 +182,7 @@ public class ManualEventsController : ControllerBase
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
var deletedCount = await _context.ManualEvents
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
@@ -12,14 +12,10 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class StatsController : ControllerBase
{
private readonly ILogger<StatsController> _logger;
private readonly IStatsService _statsService;
public StatsController(
ILogger<StatsController> logger,
IStatsService statsService)
public StatsController(IStatsService statsService)
{
_logger = logger;
_statsService = statsService;
}
@@ -35,19 +31,11 @@ public class StatsController : ControllerBase
[FromQuery] int includeEvents = 0,
[FromQuery] int includeStrikes = 0)
{
try
{
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving stats");
return StatusCode(500, new { Error = "An error occurred while retrieving stats" });
}
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
}
@@ -13,16 +13,13 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
public StatusController(
ILogger<StatusController> logger,
DataContext dataContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
@@ -30,247 +27,219 @@ public class StatusController : ControllerBase
[HttpGet]
public async Task<IActionResult> GetSystemStatus()
{
try
{
var process = Process.GetCurrentProcess();
// Get configuration
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var sonarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Sonarr);
var radarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Radarr);
var lidarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
Application = new
{
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
process.StartTime,
UpTime = DateTime.Now - process.StartTime,
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
ProcessorTime = process.TotalProcessorTime
},
DownloadClient = new
{
// TODO
},
MediaManagers = new
{
Sonarr = new
{
InstanceCount = sonarrConfig.Instances.Count
},
Radarr = new
{
InstanceCount = radarrConfig.Instances.Count
},
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};
using var process = Process.GetCurrentProcess();
return Ok(status);
}
catch (Exception ex)
// Get configuration
var sonarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Sonarr);
var radarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Radarr);
var lidarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
_logger.LogError(ex, "Error retrieving system status");
return StatusCode(500, "An error occurred while retrieving system status");
}
Application = new
{
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
process.StartTime,
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
ProcessorTime = process.TotalProcessorTime
},
DownloadClient = new
{
// TODO
},
MediaManagers = new
{
Sonarr = new
{
InstanceCount = sonarrConfig.Instances.Count
},
Radarr = new
{
InstanceCount = radarrConfig.Instances.Count
},
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};
return Ok(status);
}
[HttpGet("download-client")]
public async Task<IActionResult> GetDownloadClientStatus()
{
try
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var result = new Dictionary<string, object>();
// Check for configured clients
if (downloadClients.Count > 0)
{
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var result = new Dictionary<string, object>();
// Check for configured clients
if (downloadClients.Count > 0)
var clientsStatus = new List<object>();
foreach (var client in downloadClients)
{
var clientsStatus = new List<object>();
foreach (var client in downloadClients)
clientsStatus.Add(new
{
clientsStatus.Add(new
{
client.Id,
client.Name,
Type = client.TypeName,
client.Host,
client.Enabled,
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
});
}
result["Clients"] = clientsStatus;
client.Id,
client.Name,
Type = client.TypeName,
client.Host,
client.Enabled,
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
});
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving download client status");
return StatusCode(500, "An error occurred while retrieving download client status");
result["Clients"] = clientsStatus;
}
return Ok(result);
}
[HttpGet("arrs")]
public async Task<IActionResult> GetMediaManagersStatus()
{
try
var status = new Dictionary<string, object>();
// Get configurations
var enabledSonarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Sonarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledRadarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Radarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledLidarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Lidarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
// Check Sonarr instances
var sonarrStatus = new List<object>();
foreach (var instance in enabledSonarrInstances)
{
var status = new Dictionary<string, object>();
// Get configurations
var enabledSonarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Sonarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledRadarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Radarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledLidarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Lidarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();;
// Check Sonarr instances
var sonarrStatus = new List<object>();
foreach (var instance in enabledSonarrInstances)
try
{
try
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
sonarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
status["Sonarr"] = sonarrStatus;
// Check Radarr instances
var radarrStatus = new List<object>();
foreach (var instance in enabledRadarrInstances)
catch (Exception ex)
{
try
sonarrStatus.Add(new
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
status["Radarr"] = radarrStatus;
// Check Lidarr instances
var lidarrStatus = new List<object>();
foreach (var instance in enabledLidarrInstances)
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Lidarr"] = lidarrStatus;
return Ok(status);
}
catch (Exception ex)
status["Sonarr"] = sonarrStatus;
// Check Radarr instances
var radarrStatus = new List<object>();
foreach (var instance in enabledRadarrInstances)
{
_logger.LogError(ex, "Error retrieving media managers status");
return StatusCode(500, "An error occurred while retrieving media managers status");
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Radarr"] = radarrStatus;
// Check Lidarr instances
var lidarrStatus = new List<object>();
foreach (var instance in enabledLidarrInstances)
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Lidarr"] = lidarrStatus;
return Ok(status);
}
}
@@ -178,8 +178,8 @@ public class DownloadItemStrikesDto
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 DateTimeOffset LatestStrikeAt { get; set; }
public DateTimeOffset FirstStrikeAt { get; set; }
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
@@ -191,7 +191,7 @@ public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
public bool IsDryRun { get; set; }
@@ -201,7 +201,7 @@ public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsDryRun { get; set; }
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Cleanuparr.Api.Filters;
@@ -55,24 +56,42 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
services.AddCleanuparrProblemDetails();
services.AddExceptionHandler<GlobalExceptionHandler>();
return services;
}
/// <summary>
/// Registers RFC 9457 problem-details responses for both the exception handler and
/// [ApiController] model-state validation, attaching a uniform Activity-tied traceId.
/// </summary>
public static IServiceCollection AddCleanuparrProblemDetails(this IServiceCollection services)
{
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions.TryAdd(
"traceId", Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
});
return services;
}
public static WebApplication ConfigureApi(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
// Map unhandled exceptions to RFC 9457 problem-details responses (GlobalExceptionHandler).
// Registered first so it also covers exceptions thrown by downstream middleware.
app.UseExceptionHandler();
// Enable compression
app.UseResponseCompression();
// Serve static files without caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
});
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
// Resolve the real client IP / scheme / host from X-Forwarded-* headers
app.UseMiddleware<TrustedForwardedHeadersMiddleware>();
@@ -4,6 +4,7 @@ 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.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
@@ -47,6 +48,10 @@ public static class ServicesDI
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
.AddScoped<IDeadTorrentService, DeadTorrentService>()
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
.AddScoped<Seeker>()
.AddScoped<CustomFormatScoreSyncer>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Extensions;
public static class ControllerBaseExtensions
{
/// <summary>
/// Builds an RFC 9457 problem-details error response for a direct (non-throwing) controller return.
/// Mirrors the shape produced by <see cref="Middleware.GlobalExceptionHandler"/> so every error
/// response carries the same <c>application/problem+json</c> body and <c>traceId</c>. The
/// <c>traceId</c> extension is added by the shared <c>CustomizeProblemDetails</c> hook inside
/// <see cref="ProblemDetailsFactory.CreateProblemDetails"/>.
/// </summary>
public static ObjectResult ProblemResult(
this ControllerBase controller,
int statusCode,
string detail,
string? title = null,
IReadOnlyDictionary<string, object?>? extensions = null)
{
ProblemDetails problemDetails = controller.ProblemDetailsFactory
.CreateProblemDetails(controller.HttpContext, statusCode: statusCode, title: title, detail: detail);
if (extensions is not null)
{
foreach (KeyValuePair<string, object?> extension in extensions)
{
problemDetails.Extensions[extension.Key] = extension.Value;
}
}
return new ObjectResult(problemDetails)
{
StatusCode = statusCode,
ContentTypes = { "application/problem+json" },
};
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
@@ -182,11 +183,6 @@ public sealed class ArrConfigController : ControllerBase
return Ok(new { Message = $"{type} configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save {Type} configuration", type);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -207,11 +203,6 @@ public sealed class ArrConfigController : ControllerBase
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create {Type} instance", type);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -230,7 +221,7 @@ public sealed class ArrConfigController : ControllerBase
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
}
request.ApplyTo(instance);
@@ -239,11 +230,6 @@ public sealed class ArrConfigController : ControllerBase
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -262,7 +248,7 @@ public sealed class ArrConfigController : ControllerBase
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
}
config.Instances.Remove(instance);
@@ -270,11 +256,6 @@ public sealed class ArrConfigController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -295,7 +276,7 @@ public sealed class ArrConfigController : ControllerBase
if (existingInstance is null)
{
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Instance with ID {request.InstanceId.Value} not found");
}
resolvedApiKey = existingInstance.ApiKey;
@@ -310,7 +291,7 @@ public sealed class ArrConfigController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
}
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
/// <summary>
/// Request to record that the current user has seen the given features, used to drive the "NEW" feature badges in the UI.
/// </summary>
public sealed record RecordFeatureViewsRequest
{
/// <summary>
/// The feature identifiers the user has been exposed to.
/// Unknown ids are recorded with the current timestamp; already-seen ids are ignored.
/// </summary>
[Required]
[MaxLength(100)]
public required IReadOnlyList<string> FeatureIds { get; init; }
}
@@ -0,0 +1,15 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record FeatureViewsResponse
{
/// <summary>
/// The user's account creation timestamp, used as the anchor for "new feature" detection:
/// a feature is only considered new if it was first seen meaningfully after this point.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Map of feature id to the UTC timestamp the user first saw it.
/// </summary>
public required Dictionary<string, DateTimeOffset> Views { get; init; }
}
@@ -4,13 +4,13 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.Auth.Controllers;
@@ -67,7 +67,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Password changes are disabled while OIDC exclusive mode is active.");
}
var user = await GetCurrentUser();
@@ -78,10 +78,10 @@ public sealed class AccountController : ControllerBase
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
{
return BadRequest(new { error = "Current password is incorrect" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
}
DateTime now = DateTime.UtcNow;
DateTimeOffset now = DateTimeOffset.UtcNow;
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
user.UpdatedAt = now;
@@ -115,12 +115,12 @@ public sealed class AccountController : ControllerBase
// Verify current credentials
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
}
// Generate new TOTP
@@ -129,7 +129,7 @@ public sealed class AccountController : ControllerBase
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Replace recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -168,12 +168,12 @@ public sealed class AccountController : ControllerBase
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
// Generate new TOTP
@@ -182,7 +182,7 @@ public sealed class AccountController : ControllerBase
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Replace any existing recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -221,21 +221,21 @@ public sealed class AccountController : ControllerBase
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid verification code");
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
@@ -254,22 +254,22 @@ public sealed class AccountController : ControllerBase
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA is not enabled" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "2FA is not enabled");
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
}
user.TotpEnabled = false;
user.TotpSecret = string.Empty;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Remove all recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -307,7 +307,7 @@ public sealed class AccountController : ControllerBase
rng.GetBytes(bytes);
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
@@ -320,7 +320,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var pin = await _plexAuthService.RequestPin();
@@ -333,7 +333,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
@@ -355,7 +355,7 @@ public sealed class AccountController : ControllerBase
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
@@ -369,7 +369,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var user = await GetCurrentUser();
@@ -382,7 +382,7 @@ public sealed class AccountController : ControllerBase
user.PlexUsername = null;
user.PlexEmail = null;
user.PlexAuthToken = null;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
@@ -405,25 +405,18 @@ public sealed class AccountController : ControllerBase
[HttpPut("oidc")]
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
{
try
var user = await GetCurrentUser();
if (user is null)
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
[HttpPost("oidc/link")]
@@ -437,7 +430,7 @@ public sealed class AccountController : ControllerBase
if (user.Oidc is not { Enabled: true })
{
return BadRequest(new { error = "OIDC is not enabled" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled");
}
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
@@ -450,8 +443,7 @@ public sealed class AccountController : ControllerBase
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
return StatusCode(429, new { error = ex.Message });
throw new RateLimitException(ex.Message, ex);
}
}
@@ -504,7 +496,7 @@ public sealed class AccountController : ControllerBase
}
user.Oidc.AuthorizedSubject = result.Subject;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
@@ -527,7 +519,7 @@ public sealed class AccountController : ControllerBase
user.Oidc.AuthorizedSubject = string.Empty;
user.Oidc.ExclusiveMode = false;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
@@ -540,6 +532,79 @@ public sealed class AccountController : ControllerBase
}
}
private const int MaxFeatureIdsPerRequest = 100;
private const int MaxFeatureIdLength = 64;
/// <summary>
/// Records that the current user has seen the given features, used to drive the "NEW" feature badges in the UI.
/// Recording is idempotent: unknown ids are stamped with the current time, already-seen ids keep their original timestamp.
/// </summary>
/// <param name="request">The feature ids the user has been exposed to.</param>
/// <returns>
/// The user's account creation timestamp and the full map of feature id to first-seen timestamp.
/// </returns>
[HttpPost("feature-views")]
public async Task<IActionResult> RecordFeatureViews([FromBody] RecordFeatureViewsRequest request)
{
if (request.FeatureIds.Count > MaxFeatureIdsPerRequest)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, $"featureIds exceeds the maximum allowed ({MaxFeatureIdsPerRequest}).");
}
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
var existing = await _usersContext.UserFeatureViews
.Where(v => v.UserId == user.Id)
.ToListAsync();
var existingIds = existing
.Select(v => v.FeatureId)
.ToHashSet();
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (var featureId in request.FeatureIds.Distinct())
{
if (string.IsNullOrWhiteSpace(featureId) ||
featureId.Length > MaxFeatureIdLength ||
existingIds.Contains(featureId))
{
continue;
}
var view = new UserFeatureView
{
Id = Guid.NewGuid(),
UserId = user.Id,
FeatureId = featureId,
FirstSeenAt = now
};
_usersContext.UserFeatureViews.Add(view);
existing.Add(view);
}
await _usersContext.SaveChangesAsync();
return Ok(new FeatureViewsResponse
{
CreatedAt = user.CreatedAt,
Views = existing.ToDictionary(v => v.FeatureId, v => v.FirstSeenAt)
});
}
finally
{
UsersContext.Lock.Release();
}
}
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
{
var baseUrl = string.IsNullOrEmpty(redirectUrl)
@@ -4,6 +4,7 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
@@ -92,7 +93,7 @@ public sealed class AuthController : ControllerBase
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
if (existingUser is not null)
{
return Conflict(new { error = "Account already exists" });
return this.ProblemResult(StatusCodes.Status409Conflict, "Account already exists");
}
var user = new User
@@ -104,8 +105,8 @@ public sealed class AuthController : ControllerBase
TotpEnabled = false,
ApiKey = GenerateApiKey(),
SetupCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_usersContext.Users.Add(user);
@@ -133,12 +134,12 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
}
// Generate new TOTP secret
@@ -150,7 +151,7 @@ public sealed class AuthController : ControllerBase
// Store secret (will be finalized on verify)
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Remove old recovery codes and add new ones
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -190,26 +191,26 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return Unauthorized(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
@@ -231,16 +232,16 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed" });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed");
}
user.SetupCompleted = true;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Setup completed for user {Username}", user.Username);
@@ -258,7 +259,7 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
@@ -270,20 +271,21 @@ public sealed class AuthController : ControllerBase
if (user is null || !user.SetupCompleted)
{
return Unauthorized(new { error = "Invalid credentials" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials");
}
// Check lockout
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTimeOffset.UtcNow)
{
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds);
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
int remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
throw new RateLimitException("Account is locked", remaining);
}
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
{
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
int retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials",
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
}
// Reset failed attempts on successful password verification
@@ -320,13 +322,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
}
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
if (userId is null)
{
return Unauthorized(new { error = "Invalid or expired login token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired login token");
}
var user = await _usersContext.Users
@@ -335,7 +337,7 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return Unauthorized(new { error = "Invalid login token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid login token");
}
bool codeValid;
@@ -351,7 +353,7 @@ public sealed class AuthController : ControllerBase
if (!codeValid)
{
return Unauthorized(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
}
return Ok(await GenerateTokenResponse(user));
@@ -369,13 +371,13 @@ public sealed class AuthController : ControllerBase
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
if (storedToken is null || storedToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return Unauthorized(new { error = "Invalid or expired refresh token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired refresh token");
}
// Revoke the old token (rotation)
storedToken.RevokedAt = DateTime.UtcNow;
storedToken.RevokedAt = DateTimeOffset.UtcNow;
// Generate new tokens
var response = await GenerateTokenResponse(storedToken.User);
@@ -402,7 +404,7 @@ public sealed class AuthController : ControllerBase
if (storedToken is not null)
{
storedToken.RevokedAt = DateTime.UtcNow;
storedToken.RevokedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
}
@@ -420,12 +422,12 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
}
var pin = await _plexAuthService.RequestPin();
@@ -455,19 +457,19 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
@@ -486,13 +488,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
}
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" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pin = await _plexAuthService.RequestPin();
@@ -509,13 +511,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
}
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" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
@@ -530,7 +532,7 @@ public sealed class AuthController : ControllerBase
if (plexAccount.AccountId != user.PlexAccountId)
{
return Unauthorized(new { error = "Plex account does not match the linked account" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Plex account does not match the linked account");
}
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
@@ -558,7 +560,7 @@ public sealed class AuthController : ControllerBase
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
string.IsNullOrEmpty(oidcConfig.ClientId))
{
return BadRequest(new { error = "OIDC is not enabled or not configured" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled or not configured");
}
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
@@ -571,8 +573,7 @@ public sealed class AuthController : ControllerBase
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC authorization");
return StatusCode(429, new { error = ex.Message });
throw new RateLimitException(ex.Message, ex);
}
}
@@ -642,7 +643,7 @@ public sealed class AuthController : ControllerBase
if (result is null)
{
return NotFound(new { error = "Invalid or expired code" });
return this.ProblemResult(StatusCodes.Status404NotFound, "Invalid or expired code");
}
return Ok(new TokenResponse
@@ -671,8 +672,8 @@ public sealed class AuthController : ControllerBase
Id = Guid.NewGuid(),
UserId = user.Id,
TokenHash = HashRefreshToken(refreshToken),
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
CreatedAt = DateTimeOffset.UtcNow
});
await _usersContext.SaveChangesAsync();
@@ -695,7 +696,7 @@ public sealed class AuthController : ControllerBase
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
{
recoveryCode.IsUsed = true;
recoveryCode.UsedAt = DateTime.UtcNow;
recoveryCode.UsedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
@@ -718,7 +719,7 @@ public sealed class AuthController : ControllerBase
{
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
user.FailedLoginAttempts++;
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
user.LockoutEnd = DateTimeOffset.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
await _usersContext.SaveChangesAsync();
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
@@ -89,11 +89,6 @@ public sealed class BlacklistSyncConfigController : ControllerBase
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -0,0 +1,14 @@
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record DeadTorrentConfigRequest
{
public bool Enabled { get; init; }
public string TargetCategory { get; init; } = "cleanuparr-dead";
public bool UseTag { get; init; }
public ushort MaxStrikes { get; init; }
public List<string> Categories { get; init; } = [];
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record OrphanedFilesConfigRequest
{
public bool Enabled { get; init; }
public List<string> ScanDirectories { get; init; } = [];
[Required]
public string OrphanedDirectory { get; init; } = string.Empty;
public List<string> ExcludePatterns { get; init; } = [];
[Range(0, int.MaxValue)]
public int MinFileAgeHours { get; init; } = 24;
[Range(1, int.MaxValue)]
public int? PurgeAfterHours { get; init; }
}
@@ -58,6 +58,12 @@ public record SeedingRuleRequest
/// </summary>
public double MaxSeedTime { get; init; } = -1;
/// <summary>
/// Minimum number of seeders required before removing a download. Set to 0 to disable.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "Min seeders must be 0 or greater.")]
public int MinSeeders { get; init; }
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
@@ -11,8 +11,4 @@ public sealed record UnlinkedConfigRequest
public List<string> IgnoredRootDirs { get; init; } = [];
public List<string> Categories { get; init; } = [];
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
}
@@ -0,0 +1,25 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record DeadTorrentConfigResponse
{
public bool Enabled { get; init; }
public required string TargetCategory { get; init; }
public bool UseTag { get; init; }
public ushort MaxStrikes { get; init; }
public required List<string> Categories { get; init; }
public static DeadTorrentConfigResponse From(DeadTorrentConfig config) => new()
{
Enabled = config.Enabled,
TargetCategory = config.TargetCategory,
UseTag = config.UseTag,
MaxStrikes = config.MaxStrikes,
Categories = config.Categories,
};
}
@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record DownloadCleanerClientResponse
{
public Guid DownloadClientId { get; init; }
public required string DownloadClientName { get; init; }
public bool DownloadClientEnabled { get; init; }
public DownloadClientTypeName DownloadClientTypeName { get; init; }
public required IReadOnlyList<SeedingRuleResponse> SeedingRules { get; init; }
public UnlinkedConfigResponse? UnlinkedConfig { get; init; }
public DeadTorrentConfigResponse? DeadTorrentConfig { get; init; }
public OrphanedFilesConfigResponse? OrphanedFilesConfig { get; init; }
}
@@ -0,0 +1,28 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record OrphanedFilesConfigResponse
{
public bool Enabled { get; init; }
public required List<string> ScanDirectories { get; init; }
public required string OrphanedDirectory { get; init; }
public required List<string> ExcludePatterns { get; init; }
public int MinFileAgeHours { get; init; }
public int? PurgeAfterHours { get; init; }
public static OrphanedFilesConfigResponse From(OrphanedFilesConfig config) => new()
{
Enabled = config.Enabled,
ScanDirectories = config.ScanDirectories,
OrphanedDirectory = config.OrphanedDirectory,
ExcludePatterns = config.ExcludePatterns,
MinFileAgeHours = config.MinFileAgeHours,
PurgeAfterHours = config.PurgeAfterHours,
};
}
@@ -0,0 +1,50 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record SeedingRuleResponse
{
public Guid Id { get; init; }
public required string Name { get; init; }
public required List<string> Categories { get; init; }
public required List<string> TrackerPatterns { get; init; }
public required List<string> TagsAny { get; init; }
public required List<string> TagsAll { get; init; }
public int Priority { get; init; }
public TorrentPrivacyType PrivacyType { get; init; }
public double MaxRatio { get; init; }
public double MinSeedTime { get; init; }
public double MaxSeedTime { get; init; }
public int? MinSeeders { get; init; }
public bool DeleteSourceFiles { get; init; }
public static SeedingRuleResponse From(ISeedingRule rule) => new()
{
Id = rule.Id,
Name = rule.Name,
Categories = rule.Categories,
TrackerPatterns = rule.TrackerPatterns,
TagsAny = (rule as ITagFilterable)?.TagsAny ?? [],
TagsAll = (rule as ITagFilterable)?.TagsAll ?? [],
Priority = rule.Priority,
PrivacyType = rule.PrivacyType,
MaxRatio = rule.MaxRatio,
MinSeedTime = rule.MinSeedTime,
MaxSeedTime = rule.MaxSeedTime,
MinSeeders = (rule as ISeedersFilterable)?.MinSeeders,
DeleteSourceFiles = rule.DeleteSourceFiles,
};
}
@@ -0,0 +1,25 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record UnlinkedConfigResponse
{
public bool Enabled { get; init; }
public required string TargetCategory { get; init; }
public bool UseTag { get; init; }
public required List<string> IgnoredRootDirs { get; init; }
public required List<string> Categories { get; init; }
public static UnlinkedConfigResponse From(UnlinkedConfig config) => new()
{
Enabled = config.Enabled,
TargetCategory = config.TargetCategory,
UseTag = config.UseTag,
IgnoredRootDirs = config.IgnoredRootDirs,
Categories = config.Categories,
};
}
@@ -0,0 +1,108 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/dead-torrent-config")]
[Authorize]
public class DeadTorrentConfigController : ControllerBase
{
private readonly ILogger<DeadTorrentConfigController> _logger;
private readonly DataContext _dataContext;
public DeadTorrentConfigController(
ILogger<DeadTorrentConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetDeadTorrentConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.DeadTorrentConfigs
.AsNoTracking()
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
return Ok(config is null ? null : DeadTorrentConfigResponse.From(config));
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateDeadTorrentConfig(Guid downloadClientId, [FromBody] DeadTorrentConfigRequest dto)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
if (dto.Enabled && client.TypeName is DownloadClientTypeName.rTorrent)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, "Dead torrent handling is not supported for rTorrent (no seeder count available)");
}
var existing = await _dataContext.DeadTorrentConfigs
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
if (existing is null)
{
existing = new DeadTorrentConfig
{
DownloadClientConfigId = downloadClientId,
};
_dataContext.DeadTorrentConfigs.Add(existing);
}
existing.Enabled = dto.Enabled;
existing.TargetCategory = dto.TargetCategory;
existing.UseTag = dto.UseTag;
existing.MaxStrikes = dto.MaxStrikes;
existing.Categories = dto.Categories;
existing.Validate();
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated dead torrent config for client {ClientId}", downloadClientId);
return Ok(DeadTorrentConfigResponse.From(existing));
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
@@ -52,49 +53,40 @@ public sealed class DownloadCleanerConfigController : ControllerBase
var allTransmissionRules = await _dataContext.TransmissionSeedingRules.AsNoTracking().ToListAsync();
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
var allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
List<UnlinkedConfig> allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
List<DeadTorrentConfig> allDeadTorrentConfigs = await _dataContext.DeadTorrentConfigs.AsNoTracking().ToListAsync();
List<OrphanedFilesConfig> allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync();
var clients = new List<object>();
Dictionary<Guid, UnlinkedConfig> unlinkedConfigsByClientId = allUnlinkedConfigs
.GroupBy(u => u.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
Dictionary<Guid, DeadTorrentConfig> deadTorrentConfigsByClientId = allDeadTorrentConfigs
.GroupBy(d => d.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
Dictionary<Guid, OrphanedFilesConfig> orphanedFilesConfigsByClientId = allOrphanedFilesConfigs
.GroupBy(o => o.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
var clients = new List<DownloadCleanerClientResponse>();
foreach (var client in downloadClients)
{
var seedingRules = SeedingRuleHelper.FilterForClient(
client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
var unlinkedConfig = allUnlinkedConfigs.FirstOrDefault(u => u.DownloadClientConfigId == client.Id);
List<ISeedingRule> seedingRules = SeedingRuleHelper
.FilterForClient(client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
unlinkedConfigsByClientId.TryGetValue(client.Id, out UnlinkedConfig? unlinkedConfig);
deadTorrentConfigsByClientId.TryGetValue(client.Id, out DeadTorrentConfig? deadTorrentConfig);
orphanedFilesConfigsByClientId.TryGetValue(client.Id, out OrphanedFilesConfig? orphanedFilesConfig);
clients.Add(new
clients.Add(new DownloadCleanerClientResponse
{
downloadClientId = client.Id,
downloadClientName = client.Name,
downloadClientEnabled = client.Enabled,
downloadClientTypeName = client.TypeName,
seedingRules = seedingRules.Select(r => new
{
id = r.Id,
name = r.Name,
categories = r.Categories,
trackerPatterns = r.TrackerPatterns,
tagsAny = (r as ITagFilterable)?.TagsAny ?? new List<string>(),
tagsAll = (r as ITagFilterable)?.TagsAll ?? new List<string>(),
priority = r.Priority,
privacyType = r.PrivacyType,
maxRatio = r.MaxRatio,
minSeedTime = r.MinSeedTime,
maxSeedTime = r.MaxSeedTime,
deleteSourceFiles = r.DeleteSourceFiles,
}),
unlinkedConfig = unlinkedConfig is not null
? new
{
enabled = unlinkedConfig.Enabled,
targetCategory = unlinkedConfig.TargetCategory,
useTag = unlinkedConfig.UseTag,
ignoredRootDirs = unlinkedConfig.IgnoredRootDirs,
categories = unlinkedConfig.Categories,
downloadDirectorySource = unlinkedConfig.DownloadDirectorySource,
downloadDirectoryTarget = unlinkedConfig.DownloadDirectoryTarget,
}
: null,
DownloadClientId = client.Id,
DownloadClientName = client.Name,
DownloadClientEnabled = client.Enabled,
DownloadClientTypeName = client.TypeName,
SeedingRules = seedingRules.Select(SeedingRuleResponse.From).ToList(),
UnlinkedConfig = unlinkedConfig is not null ? UnlinkedConfigResponse.From(unlinkedConfig) : null,
DeadTorrentConfig = deadTorrentConfig is not null ? DeadTorrentConfigResponse.From(deadTorrentConfig) : null,
OrphanedFilesConfig = orphanedFilesConfig is not null ? OrphanedFilesConfigResponse.From(orphanedFilesConfig) : null,
});
}
@@ -144,15 +136,6 @@ public sealed class DownloadCleanerConfigController : ControllerBase
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -0,0 +1,120 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/orphaned-files-config")]
[Authorize]
public sealed class OrphanedFilesConfigController : ControllerBase
{
private readonly ILogger<OrphanedFilesConfigController> _logger;
private readonly DataContext _dataContext;
public OrphanedFilesConfigController(
ILogger<OrphanedFilesConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetClientConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
return Ok(config is null ? null : OrphanedFilesConfigResponse.From(config));
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateClientConfig(Guid downloadClientId, [FromBody] OrphanedFilesConfigRequest dto)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existing = await _dataContext.OrphanedFilesConfigs
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
var candidate = (existing ?? new OrphanedFilesConfig { DownloadClientConfigId = downloadClientId }) with
{
Enabled = dto.Enabled,
ScanDirectories = dto.ScanDirectories,
OrphanedDirectory = dto.OrphanedDirectory,
ExcludePatterns = dto.ExcludePatterns,
MinFileAgeHours = dto.MinFileAgeHours,
PurgeAfterHours = dto.PurgeAfterHours,
};
var siblings = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.Where(c => c.DownloadClientConfigId != downloadClientId)
.ToListAsync();
var otherDownloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.Where(c => c.Id != downloadClientId)
.ToListAsync();
candidate.Validate(siblings, otherDownloadClients);
if (existing is null)
{
_dataContext.OrphanedFilesConfigs.Add(candidate);
}
else
{
existing.Enabled = candidate.Enabled;
existing.ScanDirectories = candidate.ScanDirectories;
existing.OrphanedDirectory = candidate.OrphanedDirectory;
existing.ExcludePatterns = candidate.ExcludePatterns;
existing.MinFileAgeHours = candidate.MinFileAgeHours;
existing.PurgeAfterHours = candidate.PurgeAfterHours;
}
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated orphaned files client config for client {ClientId}", downloadClientId);
return Ok(OrphanedFilesConfigResponse.From(existing ?? candidate));
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -1,4 +1,6 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -7,7 +9,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
@@ -39,31 +40,12 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
return Ok(rules.Select(r => new
{
id = r.Id,
name = r.Name,
categories = r.Categories,
trackerPatterns = r.TrackerPatterns,
tagsAny = (r as ITagFilterable)?.TagsAny ?? new List<string>(),
tagsAll = (r as ITagFilterable)?.TagsAll ?? new List<string>(),
priority = r.Priority,
privacyType = r.PrivacyType,
maxRatio = r.MaxRatio,
minSeedTime = r.MinSeedTime,
maxSeedTime = r.MaxSeedTime,
deleteSourceFiles = r.DeleteSourceFiles,
}));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve seeding rules", Error = ex.Message });
return Ok(rules.Select(SeedingRuleResponse.From));
}
finally
{
@@ -74,11 +56,6 @@ public class SeedingRulesController : ControllerBase
[HttpPost("{downloadClientId}")]
public async Task<IActionResult> CreateSeedingRule(Guid downloadClientId, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -88,14 +65,14 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
if (ruleDto.Priority.HasValue && existingRules.Any(r => r.Priority == ruleDto.Priority.Value))
{
return BadRequest(new { Message = $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client");
}
int priority = ruleDto.Priority ?? (existingRules.Count == 0 ? 1 : existingRules.Max(r => r.Priority) + 1);
@@ -111,17 +88,6 @@ public class SeedingRulesController : ControllerBase
return CreatedAtAction(nameof(GetSeedingRules), new { downloadClientId }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create seeding rule: {RuleName} for client {ClientId}",
ruleDto.Name, downloadClientId);
return StatusCode(500, new { Message = "Failed to create seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -131,11 +97,6 @@ public class SeedingRulesController : ControllerBase
[HttpPut("{id}")]
public async Task<IActionResult> UpdateSeedingRule(Guid id, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -143,7 +104,7 @@ public class SeedingRulesController : ControllerBase
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeding rule with ID {id} not found");
}
existingRule.Name = ruleDto.Name.Trim();
@@ -162,6 +123,11 @@ public class SeedingRulesController : ControllerBase
tagFilterable.TagsAll = SanitizeStringList(ruleDto.TagsAll);
}
if (existingRule is ISeedersFilterable seedersFilterable)
{
seedersFilterable.MinSeeders = ruleDto.MinSeeders;
}
existingRule.Validate();
await _dataContext.SaveChangesAsync();
@@ -170,16 +136,6 @@ public class SeedingRulesController : ControllerBase
return Ok(existingRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -189,11 +145,6 @@ public class SeedingRulesController : ControllerBase
[HttpPut("{downloadClientId}/reorder")]
public async Task<IActionResult> ReorderSeedingRules(Guid downloadClientId, [FromBody] ReorderSeedingRulesRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -203,24 +154,24 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
List<ISeedingRule> rules = await SeedingRuleHelper.GetForClientTrackedAsync(_dataContext, client);
if (request.OrderedIds.Distinct().Count() != request.OrderedIds.Count)
{
return BadRequest(new { Message = "Duplicate rule IDs are not allowed" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Duplicate rule IDs are not allowed");
}
if (request.OrderedIds.Count != rules.Count)
{
return BadRequest(new { Message = $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included." });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included.");
}
foreach (Guid id in request.OrderedIds.Where(id => rules.All(r => r.Id != id)))
{
return BadRequest(new { Message = $"Rule with ID {id} not found for client {downloadClientId}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Rule with ID {id} not found for client {downloadClientId}");
}
int priority = 1;
@@ -237,11 +188,6 @@ public class SeedingRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reorder seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to reorder seeding rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -258,7 +204,7 @@ public class SeedingRulesController : ControllerBase
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeding rule with ID {id} not found");
}
RemoveRuleFromDbSet(existingRule);
@@ -268,11 +214,6 @@ public class SeedingRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -304,6 +245,7 @@ public class SeedingRulesController : ControllerBase
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.Deluge => new DelugeSeedingRule
@@ -317,6 +259,7 @@ public class SeedingRulesController : ControllerBase
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.Transmission => new TransmissionSeedingRule
@@ -332,6 +275,7 @@ public class SeedingRulesController : ControllerBase
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.uTorrent => new UTorrentSeedingRule
@@ -345,6 +289,7 @@ public class SeedingRulesController : ControllerBase
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.rTorrent => new RTorrentSeedingRule
@@ -1,11 +1,12 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
@@ -37,19 +38,14 @@ public class UnlinkedConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.UnlinkedConfigs
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
return Ok(config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve unlinked config", Error = ex.Message });
return Ok(config is null ? null : UnlinkedConfigResponse.From(config));
}
finally
{
@@ -60,11 +56,6 @@ public class UnlinkedConfigController : ControllerBase
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateUnlinkedConfig(Guid downloadClientId, [FromBody] UnlinkedConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -74,7 +65,7 @@ public class UnlinkedConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existing = await _dataContext.UnlinkedConfigs
@@ -94,8 +85,6 @@ public class UnlinkedConfigController : ControllerBase
existing.UseTag = dto.UseTag;
existing.IgnoredRootDirs = dto.IgnoredRootDirs;
existing.Categories = dto.Categories;
existing.DownloadDirectorySource = dto.DownloadDirectorySource;
existing.DownloadDirectoryTarget = dto.DownloadDirectoryTarget;
existing.Validate();
@@ -103,17 +92,7 @@ public class UnlinkedConfigController : ControllerBase
_logger.LogInformation("Updated unlinked config for client {ClientId}", downloadClientId);
return Ok(existing);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for unlinked config update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to update unlinked config", Error = ex.Message });
return Ok(UnlinkedConfigResponse.From(existing));
}
finally
{
@@ -27,6 +27,10 @@ public sealed record CreateDownloadClientRequest
public string? ExternalUrl { get; init; }
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -66,5 +70,7 @@ public sealed record CreateDownloadClientRequest
Password = Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
DownloadDirectorySource = DownloadDirectorySource,
DownloadDirectoryTarget = DownloadDirectoryTarget,
};
}
@@ -27,6 +27,10 @@ public sealed record UpdateDownloadClientRequest
public string? ExternalUrl { get; init; }
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -61,5 +65,7 @@ public sealed record UpdateDownloadClientRequest
Password = Password.IsPlaceholder() ? existing.Password : Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
DownloadDirectorySource = DownloadDirectorySource,
DownloadDirectoryTarget = DownloadDirectoryTarget,
};
}
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
@@ -66,17 +67,13 @@ public sealed class DownloadClientController : ControllerBase
newClient.Validate();
var clientConfig = newClient.ToEntity();
clientConfig.Validate();
_dataContext.DownloadClients.Add(clientConfig);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download client");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -96,21 +93,17 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
clientToPersist.Validate();
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
await _dataContext.SaveChangesAsync();
return Ok(clientToPersist);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -128,7 +121,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
_dataContext.DownloadClients.Remove(existingClient);
@@ -141,11 +134,6 @@ public sealed class DownloadClientController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -169,7 +157,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {request.ClientId.Value} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {request.ClientId.Value} not found");
}
resolvedPassword = existingClient.Password;
@@ -188,12 +176,12 @@ public sealed class DownloadClientController : ControllerBase
});
}
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
return this.ProblemResult(StatusCodes.Status400BadRequest, healthResult.ErrorMessage ?? "Connection failed");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
}
}
}
@@ -99,11 +99,6 @@ public sealed class GeneralConfigController : ControllerBase
return Ok(new { Message = "General configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save General configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
@@ -8,6 +9,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
{
public bool Enabled { get; init; }
public JobTriggerMode TriggerMode { get; init; } = JobTriggerMode.Schedule;
public string CronExpression { get; init; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; init; }
@@ -18,6 +21,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
public bool ProcessNoContentId { get; init; }
public bool DeleteIfAnyFileBlocked { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
public BlocklistSettings Radarr { get; init; } = new();
@@ -33,11 +38,13 @@ public sealed record UpdateMalwareBlockerConfigRequest
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
{
config.Enabled = Enabled;
config.TriggerMode = TriggerMode;
config.CronExpression = CronExpression;
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
config.DeletePrivate = DeletePrivate;
config.ProcessNoContentId = ProcessNoContentId;
config.DeleteIfAnyFileBlocked = DeleteIfAnyFileBlocked;
config.Sonarr = Sonarr;
config.Radarr = Radarr;
config.Lidarr = Lidarr;
@@ -74,15 +74,6 @@ public sealed class MalwareBlockerConfigController : ControllerBase
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -91,7 +82,11 @@ public sealed class MalwareBlockerConfigController : ControllerBase
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
// Webhook-only mode keeps the feature enabled but removes the cron trigger.
bool scheduleEnabled = config.Enabled &&
config is not ContentBlockerConfig { TriggerMode: JobTriggerMode.Webhook };
if (scheduleEnabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
@@ -1,14 +1,11 @@
using System.Net;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
@@ -123,18 +120,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.ApiKey.IsPlaceholder())
{
return BadRequest("API key cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "API key cannot be a placeholder value");
}
var notifiarrConfig = new NotifiarrConfig
@@ -168,11 +165,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Notifiarr provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -187,23 +179,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.Key.IsPlaceholder())
{
return BadRequest("Key cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Key cannot be a placeholder value");
}
if (newProvider.ServiceUrls.IsPlaceholder())
{
return BadRequest("Service URLs cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Service URLs cannot be a placeholder value");
}
var appriseConfig = new AppriseConfig
@@ -240,15 +232,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Apprise provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -263,23 +246,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.Password.IsPlaceholder())
{
return BadRequest("Password cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Password cannot be a placeholder value");
}
if (newProvider.AccessToken.IsPlaceholder())
{
return BadRequest("Access token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Access token cannot be a placeholder value");
}
var ntfyConfig = new NtfyConfig
@@ -319,15 +302,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Ntfy provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -342,18 +316,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.BotToken.IsPlaceholder())
{
return BadRequest("Bot token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Bot token cannot be a placeholder value");
}
var telegramConfig = new TelegramConfig
@@ -389,15 +363,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Telegram provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -416,12 +381,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Notifiarr provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Notifiarr provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -430,7 +395,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var notifiarrConfig = new NotifiarrConfig
@@ -460,7 +425,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -472,15 +437,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -499,12 +455,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Apprise provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Apprise provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -513,7 +469,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var appriseConfig = new AppriseConfig
@@ -548,7 +504,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -560,15 +516,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -587,12 +534,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Ntfy provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Ntfy provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -601,7 +548,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var ntfyConfig = new NtfyConfig
@@ -639,7 +586,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
NtfyConfiguration = ntfyConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -651,15 +598,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -678,12 +616,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Telegram provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Telegram provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -692,7 +630,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var telegramConfig = new TelegramConfig
@@ -724,7 +662,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
TelegramConfiguration = telegramConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -736,15 +674,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Telegram provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -769,7 +698,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Notification provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Notification provider with ID {id} not found");
}
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -782,11 +711,6 @@ public sealed class NotificationProvidersController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -807,7 +731,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "API key cannot be a placeholder value" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "API key cannot be a placeholder value");
}
apiKey = existing.ApiKey;
@@ -845,8 +769,7 @@ public sealed class NotificationProvidersController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -865,7 +788,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Sensitive fields cannot be placeholder values");
}
if (key.IsPlaceholder())
@@ -912,14 +835,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (AppriseException exception)
{
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -938,7 +856,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Sensitive fields cannot be placeholder values");
}
if (password.IsPlaceholder())
@@ -990,8 +908,7 @@ public sealed class NotificationProvidersController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1009,7 +926,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Bot token cannot be a placeholder value" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Bot token cannot be a placeholder value");
}
botToken = existing.BotToken;
@@ -1047,15 +964,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (TelegramException ex)
{
_logger.LogWarning(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1100,18 +1011,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.WebhookUrl.IsPlaceholder())
{
return BadRequest("Webhook URL cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Webhook URL cannot be a placeholder value");
}
var discordConfig = new DiscordConfig
@@ -1146,15 +1057,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Discord provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1173,12 +1075,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Discord provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Discord provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -1187,7 +1089,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var discordConfig = new DiscordConfig
@@ -1218,7 +1120,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -1230,15 +1132,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Discord provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1259,7 +1152,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Webhook URL cannot be a placeholder value");
}
webhookUrl = existing.WebhookUrl;
@@ -1296,15 +1189,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (DiscordException ex)
{
_logger.LogWarning(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1316,23 +1203,23 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.ApiToken.IsPlaceholder())
{
return BadRequest("API token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "API token cannot be a placeholder value");
}
if (newProvider.UserKey.IsPlaceholder())
{
return BadRequest("User key cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "User key cannot be a placeholder value");
}
var pushoverConfig = new PushoverConfig
@@ -1372,15 +1259,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Pushover provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1399,12 +1277,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Pushover provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Pushover provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -1413,7 +1291,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var pushoverConfig = new PushoverConfig
@@ -1451,7 +1329,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
PushoverConfiguration = pushoverConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -1463,15 +1341,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1493,7 +1362,7 @@ public sealed class NotificationProvidersController : ControllerBase
if (existing is null)
{
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Sensitive fields cannot be placeholder values");
}
if (apiToken.IsPlaceholder())
@@ -1545,8 +1414,7 @@ public sealed class NotificationProvidersController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Pushover provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1558,18 +1426,18 @@ public sealed class NotificationProvidersController : ControllerBase
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
if (newProvider.ApplicationToken.IsPlaceholder())
{
return BadRequest("Application token cannot be a placeholder value");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Application token cannot be a placeholder value");
}
var gotifyConfig = new GotifyConfig
@@ -1604,15 +1472,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Gotify provider");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1631,12 +1490,12 @@ public sealed class NotificationProvidersController : ControllerBase
if (existingProvider == null)
{
return NotFound($"Gotify provider with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Gotify provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
@@ -1645,7 +1504,7 @@ public sealed class NotificationProvidersController : ControllerBase
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
return this.ProblemResult(StatusCodes.Status400BadRequest, "A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
@@ -1676,7 +1535,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = updatedProvider.OnSearchTriggered,
OnSearchItemGrabbed = updatedProvider.OnSearchItemGrabbed,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTimeOffset.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
@@ -1688,15 +1547,6 @@ public sealed class NotificationProvidersController : ControllerBase
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Gotify provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1716,7 +1566,9 @@ public sealed class NotificationProvidersController : ControllerBase
testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration);
if (existing is null)
return BadRequest(new { Message = "Application token cannot be a placeholder value" });
{
return this.ProblemResult(StatusCodes.Status400BadRequest, "Application token cannot be a placeholder value");
}
applicationToken = existing.ApplicationToken;
}
@@ -1752,15 +1604,9 @@ public sealed class NotificationProvidersController : ControllerBase
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (GotifyException ex)
{
_logger.LogWarning(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
throw new NotificationTestException($"Test failed: {ex.Message}", ex);
}
}
@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
@@ -80,15 +78,6 @@ public sealed class QueueCleanerConfigController : ControllerBase
return Ok(new { Message = "QueueCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save QueueCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -1,5 +1,5 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
@@ -43,11 +43,6 @@ public class QueueRulesController : ControllerBase
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve stall rules");
return StatusCode(500, new { Message = "Failed to retrieve stall rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -57,11 +52,6 @@ public class QueueRulesController : ControllerBase
[HttpPost("stall")]
public async Task<IActionResult> CreateStallRule([FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -73,7 +63,7 @@ public class QueueRulesController : ControllerBase
if (existingRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A stall rule with this name already exists");
}
var rule = new StallRule
@@ -97,7 +87,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
rule.Validate();
@@ -109,16 +99,6 @@ public class QueueRulesController : ControllerBase
return CreatedAtAction(nameof(GetStallRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create stall rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -128,11 +108,6 @@ public class QueueRulesController : ControllerBase
[HttpPut("stall/{id}")]
public async Task<IActionResult> UpdateStallRule(Guid id, [FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -141,15 +116,15 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Stall rule with ID {id} not found");
}
var duplicateRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A stall rule with this name already exists");
}
var updatedRule = existingRule with
@@ -173,7 +148,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
updatedRule.Validate();
@@ -185,16 +160,6 @@ public class QueueRulesController : ControllerBase
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -212,7 +177,7 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Stall rule with ID {id} not found");
}
_dataContext.StallRules.Remove(existingRule);
@@ -222,11 +187,6 @@ public class QueueRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -247,11 +207,6 @@ public class QueueRulesController : ControllerBase
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve slow rules");
return StatusCode(500, new { Message = "Failed to retrieve slow rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -261,11 +216,6 @@ public class QueueRulesController : ControllerBase
[HttpPost("slow")]
public async Task<IActionResult> CreateSlowRule([FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -277,7 +227,7 @@ public class QueueRulesController : ControllerBase
if (existingRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A slow rule with this name already exists");
}
var rule = new SlowRule
@@ -303,7 +253,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
rule.Validate();
@@ -315,16 +265,6 @@ public class QueueRulesController : ControllerBase
return CreatedAtAction(nameof(GetSlowRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create slow rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -334,11 +274,6 @@ public class QueueRulesController : ControllerBase
[HttpPut("slow/{id}")]
public async Task<IActionResult> UpdateSlowRule(Guid id, [FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -347,15 +282,15 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Slow rule with ID {id} not found");
}
var duplicateRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "A slow rule with this name already exists");
}
var updatedRule = existingRule with
@@ -381,7 +316,7 @@ public class QueueRulesController : ControllerBase
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
return this.ProblemResult(StatusCodes.Status400BadRequest, intervalValidationResult.ErrorMessage);
}
updatedRule.Validate();
@@ -393,16 +328,6 @@ public class QueueRulesController : ControllerBase
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -420,7 +345,7 @@ public class QueueRulesController : ControllerBase
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Slow rule with ID {id} not found");
}
_dataContext.SlowRules.Remove(existingRule);
@@ -430,11 +355,6 @@ public class QueueRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -16,11 +16,11 @@ public sealed record CustomFormatScoreEntryResponse
public string QualityProfileName { get; init; } = string.Empty;
public bool IsBelowCutoff { get; init; }
public bool IsMonitored { get; init; }
public DateTime LastSyncedAt { get; init; }
public DateTimeOffset LastSyncedAt { get; init; }
/// <summary>
/// Timestamp at which this item last saw its custom format score strictly
/// exceed the prior recorded score. Null when no upgrade has been recorded.
/// </summary>
public DateTime? LastUpgradedAt { get; init; }
public DateTimeOffset? LastUpgradedAt { get; init; }
}
@@ -4,5 +4,5 @@ public sealed record CustomFormatScoreHistoryEntryResponse
{
public int Score { get; init; }
public int CutoffScore { get; init; }
public DateTime RecordedAt { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
@@ -12,5 +12,5 @@ public sealed record CustomFormatScoreUpgradeResponse
public int PreviousScore { get; init; }
public int NewScore { get; init; }
public int CutoffScore { get; init; }
public DateTime UpgradedAt { get; init; }
public DateTimeOffset UpgradedAt { get; init; }
}
@@ -7,10 +7,10 @@ public sealed record InstanceSearchStat
public string InstanceType { get; init; } = string.Empty;
public int ItemsTracked { get; init; }
public int TotalSearchCount { get; init; }
public DateTime? LastSearchedAt { get; init; }
public DateTime? LastProcessedAt { get; init; }
public DateTimeOffset? LastSearchedAt { get; init; }
public DateTimeOffset? LastProcessedAt { get; init; }
public Guid? CurrentCycleId { get; init; }
public int CycleItemsSearched { get; init; }
public int CycleItemsTotal { get; init; }
public DateTime? CycleStartedAt { get; init; }
public DateTimeOffset? CycleStartedAt { get; init; }
}
@@ -5,14 +5,14 @@ namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SearchEventResponse
{
public Guid Id { get; init; }
public DateTime Timestamp { get; init; }
public DateTimeOffset Timestamp { get; init; }
public Guid? ArrInstanceId { get; init; }
public string? InstanceType { get; init; }
public string ItemTitle { get; init; } = string.Empty;
public SeekerSearchType SearchType { get; init; }
public SeekerSearchReason? SearchReason { get; init; }
public SearchCommandStatus? SearchStatus { get; init; }
public DateTime? CompletedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public List<string> GrabbedItems { get; init; } = [];
public Guid? CycleId { get; init; }
public bool IsDryRun { get; init; }
@@ -14,7 +14,7 @@ public sealed record SeekerInstanceConfigResponse
public List<string> SkipTags { get; init; } = [];
public DateTime? LastProcessedAt { get; init; }
public DateTimeOffset? LastProcessedAt { get; init; }
public bool ArrInstanceEnabled { get; init; }
@@ -202,7 +202,7 @@ public sealed class CustomFormatScoreController : ControllerBase
string orderByClause = BuildUpgradeOrderByClause(sortBy, ascending);
DateTime? cutoff = days > 0 ? DateTime.UtcNow.AddDays(-days) : null;
DateTimeOffset? cutoff = days > 0 ? DateTimeOffset.UtcNow.AddDays(-days) : null;
string? searchPattern = string.IsNullOrWhiteSpace(search)
? null
: EventsContext.GetLikePattern(search);
@@ -290,7 +290,7 @@ public sealed class CustomFormatScoreController : ControllerBase
PreviousScore = r.PreviousScore,
NewScore = r.NewScore,
CutoffScore = r.CutoffScore,
UpgradedAt = DateTime.SpecifyKind(r.UpgradedAt, DateTimeKind.Utc),
UpgradedAt = r.UpgradedAt,
}).ToList();
return Ok(new
@@ -336,7 +336,7 @@ public sealed class CustomFormatScoreController : ControllerBase
public int PreviousScore { get; set; }
public int NewScore { get; set; }
public int CutoffScore { get; set; }
public DateTime UpgradedAt { get; set; }
public DateTimeOffset UpgradedAt { get; set; }
}
/// <summary>
@@ -400,7 +400,7 @@ public sealed class CustomFormatScoreController : ControllerBase
int unmonitored = totalTracked - monitored;
// Count upgrades in the last 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var sevenDaysAgo = DateTimeOffset.UtcNow.AddDays(-7);
var recentHistory = await _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.Where(h => h.RecordedAt >= sevenDaysAgo)
@@ -30,8 +30,8 @@ public sealed class SearchStatsController : ControllerBase
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
DateTimeOffset sevenDaysAgo = DateTimeOffset.UtcNow.AddDays(-7);
DateTimeOffset thirtyDaysAgo = DateTimeOffset.UtcNow.AddDays(-30);
// Event counts from EventsContext
var searchEvents = _eventsContext.Events
@@ -81,7 +81,7 @@ public sealed class SearchStatsController : ControllerBase
{
InstanceId = g.Key,
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
CycleStartedAt = (DateTimeOffset?)g.Min(h => h.LastSearchedAt),
})
.ToListAsync();
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
@@ -89,7 +90,7 @@ public sealed class SeekerConfigController : ControllerBase
{
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
{
return StatusCode(503, "Database is busy, please try again");
return this.ProblemResult(StatusCodes.Status503ServiceUnavailable, "Database is busy, please try again");
}
try
@@ -194,11 +195,6 @@ public sealed class SeekerConfigController : ControllerBase
return Ok(new { Message = "Seeker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Seeker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// The *arr webhook event types Cleanuparr acts on. Unrecognized events parse to
/// <see cref="Unknown"/> and are ignored.
/// </summary>
public enum ArrWebhookEventType
{
Unknown = 0,
Test,
Grab,
}
@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used
/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored.
/// </summary>
public sealed record ArrWebhookPayload
{
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
public string? EventType { get; init; }
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
public string? DownloadId { get; init; }
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
public ArrWebhookContent? Series { get; init; }
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
public ArrWebhookContent? Movie { get; init; }
}
public sealed record ArrWebhookContent
{
public long Id { get; init; }
}
@@ -0,0 +1,109 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Webhooks.Controllers;
/// <summary>
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
/// be pasted directly into the *arr Webhook connection.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public sealed class WebhooksController : ControllerBase
{
private readonly ILogger<WebhooksController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public WebhooksController(
ILogger<WebhooksController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpPost("malware-blocker/{instanceId:guid}")]
public async Task<IActionResult> TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
{
Enum.TryParse(payload.EventType, ignoreCase: true, out ArrWebhookEventType eventType);
// The Test button sends an event we acknowledge without doing any work.
if (eventType is ArrWebhookEventType.Test)
{
_logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
return Ok();
}
if (eventType is not ArrWebhookEventType.Grab)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}",
payload.EventType, instanceId);
return Ok();
}
ArrConfig? arrConfig;
ArrInstance? instance;
ContentBlockerConfig config;
await DataContext.Lock.WaitAsync();
try
{
arrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId);
config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
}
finally
{
DataContext.Lock.Release();
}
if (arrConfig is null || instance is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"No arr instance found with id {instanceId}");
}
if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
{
return this.ProblemResult(StatusCodes.Status422UnprocessableEntity, "MalwareBlocker webhooks are only supported for Sonarr and Radarr");
}
if (!config.Enabled || config.TriggerMode is JobTriggerMode.Schedule)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled");
return Ok();
}
if (string.IsNullOrWhiteSpace(payload.DownloadId))
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)");
return Ok();
}
long contentId = arrConfig.Type switch
{
InstanceType.Sonarr => payload.Series?.Id ?? 0,
InstanceType.Radarr => payload.Movie?.Id ?? 0,
_ => 0,
};
await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type);
return Ok();
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.Jobs;
@@ -112,6 +113,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterMalwareBlockerWebhookJob(cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
await RegisterSeekerJob(seekerConfig, cancellationToken);
@@ -144,13 +146,24 @@ public class BackgroundJobManager : IHostedService
{
// Always register the job definition
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
// Only add the cron trigger when scheduling is part of the trigger mode
if (config.Enabled && config.TriggerMode is not JobTriggerMode.Webhook)
{
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger).
/// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of
/// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook
/// is received.
/// </summary>
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
}
/// <summary>
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default, string? jobKeyName = null)
where T : IHandler
{
if (_scheduler == null)
{
throw new InvalidOperationException("Scheduler not initialized");
}
string typeName = typeof(T).Name;
string typeName = jobKeyName ?? typeof(T).Name;
var jobKey = new JobKey(typeName);
// Check if job already exists
if (await _scheduler.CheckExists(jobKey, cancellationToken))
{
+31 -1
View File
@@ -48,6 +48,8 @@ public sealed class GenericJob<T> : IJob
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
SetWebhookScanTarget(context);
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
@@ -68,13 +70,41 @@ public sealed class GenericJob<T> : IJob
var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId);
if (jobRun is not null)
{
jobRun.CompletedAt = DateTime.UtcNow;
jobRun.CompletedAt = DateTimeOffset.UtcNow;
jobRun.Status = status;
await eventsContext.SaveChangesAsync();
}
}
}
/// <summary>
/// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the
/// handler via the ContextProvider so the run scans only that download. No-op for normal triggers.
/// </summary>
private static void SetWebhookScanTarget(IJobExecutionContext context)
{
JobDataMap dataMap = context.MergedJobDataMap;
if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey))
{
return;
}
if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) ||
!Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType))
{
return;
}
string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty;
long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey);
int retryIndex = dataMap.ContainsKey(WebhookScanTarget.RetryIndexKey)
? dataMap.GetInt(WebhookScanTarget.RetryIndexKey)
: 0;
ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType, retryIndex));
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
@@ -1,77 +0,0 @@
using System.Net;
using System.Text.Json;
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Exceptions;
namespace Cleanuparr.Api.Middleware;
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Generate a unique identifier for this error
string traceId = Guid.NewGuid().ToString();
// Default status code and message
int statusCode = (int)HttpStatusCode.InternalServerError;
string message = "An unexpected error occurred";
switch (exception)
{
// Handle different exception types
case ValidationException:
statusCode = (int)HttpStatusCode.BadRequest;
message = exception.Message; // Use the validation message directly
_logger.LogWarning(exception,
"Validation error {TraceId} occurred during request to {Path}",
traceId, context.Request.Path);
break;
default:
// Log other exceptions as errors with more details
_logger.LogError(exception,
"Error {TraceId} occurred during request to {Path}: {Message}",
traceId, context.Request.Path, exception.Message);
break;
}
// Create the error response
ErrorResponse errorResponse = new()
{
TraceId = traceId,
Error = message
};
// Set the response
context.Response.ContentType = "application/json";
context.Response.StatusCode = statusCode;
// Write the response
await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
}
@@ -0,0 +1,76 @@
using Cleanuparr.Domain.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Cleanuparr.Api.Middleware;
/// <summary>
/// Single source of truth for mapping unhandled exceptions to RFC 9457 problem-details responses.
/// Registered via <c>AddExceptionHandler</c> + <c>UseExceptionHandler</c>.
/// </summary>
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
private readonly ProblemDetailsFactory _problemDetailsFactory;
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ProblemDetailsFactory problemDetailsFactory,
ILogger<GlobalExceptionHandler> logger)
{
_problemDetailsService = problemDetailsService;
_problemDetailsFactory = problemDetailsFactory;
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
{
(int status, string title, string detail) = exception switch
{
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed", exception.Message),
NotificationTestException => (StatusCodes.Status400BadRequest, "Notification test failed", exception.Message),
RateLimitException => (StatusCodes.Status429TooManyRequests, "Too many requests", exception.Message),
_ => (StatusCodes.Status500InternalServerError, "An error occurred", "An unexpected error occurred"),
};
string path = Sanitize(context.Request.Path);
if (status >= StatusCodes.Status500InternalServerError)
{
_logger.LogError(exception, "Unhandled error during request to {Path}", path);
}
else
{
_logger.LogWarning(exception, "Handled {Status} during request to {Path}: {Message}",
status, path, Sanitize(exception.Message));
}
context.Response.StatusCode = status;
ProblemDetails problemDetails = _problemDetailsFactory.CreateProblemDetails(
context, statusCode: status, title: title, detail: detail);
if (exception is RateLimitException { RetryAfterSeconds: > 0 } rateLimitException)
{
problemDetails.Extensions["retryAfterSeconds"] = rateLimitException.RetryAfterSeconds;
context.Response.Headers.RetryAfter = rateLimitException.RetryAfterSeconds.ToString();
}
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = problemDetails,
Exception = exception,
});
}
/// <summary>
/// Strips line breaks from user-controlled values before they reach the logs to prevent log forging.
/// </summary>
private static string Sanitize(string? value)
{
return value is null ? string.Empty : value.Replace("\r", string.Empty).Replace("\n", string.Empty);
}
}
@@ -1,17 +0,0 @@
namespace Cleanuparr.Api.Models;
/// <summary>
/// Standardized error response model for API endpoints
/// </summary>
public class ErrorResponse
{
/// <summary>
/// User-friendly error message
/// </summary>
public required string Error { get; set; }
/// <summary>
/// Trace ID for error tracking (GUID)
/// </summary>
public required string TraceId { get; set; }
}
@@ -61,8 +61,8 @@ public sealed class BlacklistSynchronizer : IHandler
string currentHash = ComputeHash(excludedFileNames);
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
await _dryRunInterceptor.InterceptAsync(() => SyncBlacklist(currentHash, excludedFileNames));
await _dryRunInterceptor.InterceptAsync(() => RemoveOldSyncDataAsync(currentHash));
_logger.LogDebug("Blacklist synchronization completed");
}
@@ -10,7 +10,7 @@ public sealed record SearchableEpisode
public bool Monitored { get; init; }
public DateTime? AirDateUtc { get; init; }
public DateTimeOffset? AirDateUtc { get; init; }
public bool HasFile { get; init; }
@@ -18,11 +18,11 @@ public sealed record SearchableMovie
public string Status { get; init; } = string.Empty;
public DateTime? Added { get; init; }
public DateTimeOffset? Added { get; init; }
public DateTime? DigitalRelease { get; init; }
public DateTimeOffset? DigitalRelease { get; init; }
public DateTime? PhysicalRelease { get; init; }
public DateTimeOffset? PhysicalRelease { get; init; }
public DateTime? InCinemas { get; init; }
public DateTimeOffset? InCinemas { get; init; }
}
@@ -12,7 +12,7 @@ public sealed record SearchableSeries
public List<long> Tags { get; init; } = [];
public DateTime? Added { get; init; }
public DateTimeOffset? Added { get; init; }
public string Status { get; init; } = string.Empty;
@@ -33,6 +33,9 @@ public sealed record DownloadStatus
public long SeedingTime { get; init; }
public float Ratio { get; init; }
[JsonProperty("total_seeds")]
public int TotalSeeds { get; init; }
public required IReadOnlyList<Tracker> Trackers { get; init; }
@@ -43,4 +46,4 @@ public sealed record DownloadStatus
public sealed record Tracker
{
public required string Url { get; init; }
}
}
@@ -22,6 +22,11 @@ public interface ITorrentItemWrapper
double Ratio { get; }
/// <summary>
/// Total seeders reported by the download client when available.
/// </summary>
int? SeederCount { get; }
long Eta { get; }
long SeedingTimeSeconds { get; }
@@ -53,4 +58,4 @@ public interface ITorrentItemWrapper
/// <param name="ignoredDownloads">List of patterns to check against</param>
/// <returns>True if the torrent matches any ignore pattern</returns>
bool IsIgnored(IReadOnlyList<string> ignoredDownloads);
}
}
@@ -156,11 +156,11 @@ public sealed class UTorrentItem
public double ProgressPercent => Progress / 1000.0;
/// <summary>
/// Date completed as DateTime (or null if not completed)
/// Date completed as DateTimeOffset (or null if not completed)
/// </summary>
[JsonIgnore]
public DateTime? DateCompletedDateTime =>
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted).DateTime : null;
public DateTimeOffset? DateCompletedDateTime =>
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted) : null;
/// <summary>
/// Seeding time in seconds (calculated from DateCompleted to now)
@@ -172,7 +172,7 @@ public sealed class UTorrentItem
{
if (DateCompletedDateTime.HasValue)
{
return DateTime.UtcNow - DateCompletedDateTime.Value;
return DateTimeOffset.UtcNow - DateCompletedDateTime.Value;
}
return null;
@@ -11,4 +11,5 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
AtLeastOneFileBlocked,
}
@@ -7,6 +7,7 @@ public enum EventType
DownloadingMetadataStrike,
SlowSpeedStrike,
SlowTimeStrike,
DeadTorrentStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged,
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum JobTriggerMode
{
Schedule,
Webhook,
Both,
}
@@ -7,4 +7,5 @@ public enum StrikeType
FailedImport,
SlowSpeed,
SlowTime,
DeadTorrent,
}
@@ -0,0 +1,16 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Thrown when a notification provider connectivity test fails. Maps to HTTP 400 so a failed
/// test is reported as a bad request rather than an unexpected server error.
/// </summary>
public sealed class NotificationTestException : Exception
{
public NotificationTestException(string message) : base(message)
{
}
public NotificationTestException(string message, Exception inner) : base(message, inner)
{
}
}
@@ -0,0 +1,20 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Thrown when a request is rejected due to rate limiting. Maps to HTTP 429 with a
/// <c>retryAfterSeconds</c> problem-details extension and a <c>Retry-After</c> header.
/// </summary>
public sealed class RateLimitException : Exception
{
public int RetryAfterSeconds { get; }
public RateLimitException(string message, int retryAfterSeconds = 0) : base(message)
{
RetryAfterSeconds = retryAfterSeconds;
}
public RateLimitException(string message, Exception inner, int retryAfterSeconds = 0) : base(message, inner)
{
RetryAfterSeconds = retryAfterSeconds;
}
}
@@ -46,8 +46,8 @@ public sealed class OidcAuthServiceTests : IDisposable
TotpSecret = "secret",
ApiKey = "test-api-key",
SetupCompleted = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
});
_usersContext.SaveChanges();
@@ -427,8 +427,8 @@ public sealed class OidcAuthServiceTests : IDisposable
var nonce = GetFlowNonce(startResult.State);
// Token expired 1 hour ago (well outside the 2-minute clock skew)
capturedJwt = jwt.CreateIdToken(MockIssuer, MockClientId, MockSubject, nonce,
expiry: DateTime.UtcNow.AddHours(-1),
notBefore: DateTime.UtcNow.AddHours(-2));
expiry: DateTimeOffset.UtcNow.AddHours(-1),
notBefore: DateTimeOffset.UtcNow.AddHours(-2));
var callbackResult = await service.HandleCallback("code", startResult.State, MockRedirectUri);
@@ -730,7 +730,7 @@ public sealed class OidcAuthServiceTests : IDisposable
SetReflectionProperty(entry, "AccessToken", "test-access");
SetReflectionProperty(entry, "RefreshToken", "test-refresh");
SetReflectionProperty(entry, "ExpiresIn", 3600);
SetReflectionProperty(entry, "CreatedAt", DateTime.UtcNow - TimeSpan.FromSeconds(31));
SetReflectionProperty(entry, "CreatedAt", DateTimeOffset.UtcNow - TimeSpan.FromSeconds(31));
var code = "expired-test-code-" + Guid.NewGuid().ToString("N");
oneTimeCodes.GetType().GetMethod("TryAdd")!.Invoke(oneTimeCodes, new[] { code, entry });
@@ -756,7 +756,7 @@ public sealed class OidcAuthServiceTests : IDisposable
foreach (var prop in flowType.GetProperties())
{
var value = prop.Name == "CreatedAt"
? DateTime.UtcNow - age
? DateTimeOffset.UtcNow - age
: prop.GetValue(existing);
SetReflectionProperty(newEntry, prop.Name, value!);
}
@@ -781,7 +781,7 @@ public sealed class OidcAuthServiceTests : IDisposable
SetReflectionProperty(entry, "Nonce", "test-nonce");
SetReflectionProperty(entry, "CodeVerifier", "test-verifier");
SetReflectionProperty(entry, "RedirectUri", redirectUri);
SetReflectionProperty(entry, "CreatedAt", DateTime.UtcNow);
SetReflectionProperty(entry, "CreatedAt", DateTimeOffset.UtcNow);
pendingFlows.GetType().GetMethod("TryAdd")!.Invoke(pendingFlows, new[] { key, entry });
return key;
@@ -1001,23 +1001,23 @@ public sealed class OidcAuthServiceTests : IDisposable
/// <summary>Creates a signed JWT. Pass subject=null to produce a token with no 'sub' claim.</summary>
public string CreateIdToken(string issuer, string audience, string? subject, string nonce,
DateTime? expiry = null, DateTime? notBefore = null)
DateTimeOffset? expiry = null, DateTimeOffset? notBefore = null)
{
var claims = new List<Claim> { new("nonce", nonce) };
if (subject is not null)
claims.Add(new Claim("sub", subject));
var expiresAt = expiry ?? DateTime.UtcNow.AddHours(1);
var notBeforeAt = notBefore ?? DateTime.UtcNow.AddMinutes(-1);
var expiresAt = expiry ?? DateTimeOffset.UtcNow.AddHours(1);
var notBeforeAt = notBefore ?? DateTimeOffset.UtcNow.AddMinutes(-1);
var descriptor = new SecurityTokenDescriptor
{
Issuer = issuer,
Audience = audience,
Subject = new ClaimsIdentity(claims),
NotBefore = notBeforeAt,
Expires = expiresAt,
IssuedAt = notBeforeAt,
NotBefore = notBeforeAt.UtcDateTime,
Expires = expiresAt.UtcDateTime,
IssuedAt = notBeforeAt.UtcDateTime,
SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.RsaSha256)
};
@@ -47,19 +47,8 @@ public class BlacklistSynchronizerTests : IDisposable
_downloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
_dryRunInterceptor.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(ci =>
{
var action = ci.ArgAt<Delegate>(0);
var parameters = ci.ArgAt<object[]>(1);
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(ci => ci.ArgAt<Func<Task>>(0).Invoke());
// Setup FakeHttpMessageHandler for FileReader
_httpMessageHandler = new FakeHttpMessageHandler();
@@ -240,9 +229,8 @@ public class BlacklistSynchronizerTests : IDisposable
// Act
await _synchronizer.ExecuteAsync();
// Assert - Verify interceptor was called (with Delegate, not Func<object, object, Task>)
await _dryRunInterceptor.Received()
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>());
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>());
}
#endregion
@@ -449,4 +449,23 @@ public class DelugeItemWrapperTests
// Assert
result.ShouldBe(expected);
}
[Fact]
public void SeederCount_ReturnsTotalSeeds()
{
// Arrange
var downloadStatus = new DownloadStatus
{
TotalSeeds = 42,
Trackers = new List<Tracker>(),
DownloadLocation = "/test/path"
};
var wrapper = new DelugeItemWrapper(downloadStatus);
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBe(42);
}
}
@@ -45,13 +45,8 @@ public class DelugeServiceFixture : IDisposable
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public DelugeService CreateSut(DownloadClientConfig? config = null)
@@ -107,13 +102,8 @@ public class DelugeServiceFixture : IDisposable
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()
@@ -553,9 +553,9 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
{
}
private void SetMalwareBlockerContext()
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(new ContentBlockerConfig());
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
@@ -655,5 +655,45 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
.Received(1)
.ChangeFilesPriority(hash, Arg.Is<List<int>>(p => p.Count == 2 && p[0] == 1 && p[1] == 0));
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsChangeFilesPriority()
{
const string hash = "partial-malware-any-hash";
DelugeService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } },
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "malware.exe" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("malware.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
}
}
@@ -220,6 +220,21 @@ public class QBitItemWrapperTests
result.ShouldBe(expectedRatio);
}
[Fact]
public void SeederCount_ReturnsTotalSeeds()
{
// Arrange
var torrentInfo = new TorrentInfo { TotalSeeds = 5 };
var trackers = new List<TorrentTracker>();
var wrapper = new QBitItemWrapper(torrentInfo, trackers, false);
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBe(5);
}
[Fact]
public void Eta_ReturnsCorrectValue()
{
@@ -635,4 +650,4 @@ public class QBitItemWrapperTests
// Assert
result.ShouldBeFalse();
}
}
}
@@ -331,6 +331,21 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
DeleteSourceFiles = false
};
private static ITorrentItemWrapper CreateTorrentWithSeederCount(string hash, int? seederCount)
{
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns(hash);
torrent.Name.Returns($"Test {hash}");
torrent.Category.Returns("movies");
torrent.IsPrivate.Returns(false);
torrent.Ratio.Returns(2.0);
torrent.SeedingTimeSeconds.Returns((long)TimeSpan.FromHours(10).TotalSeconds);
torrent.SeederCount.Returns(seederCount);
torrent.TrackerDomains.Returns(Array.Empty<string>());
torrent.Tags.Returns(Array.Empty<string>());
return torrent;
}
private void SetupDeleteMock()
{
_fixture.ClientWrapper
@@ -380,6 +395,97 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
public async Task SkipsTorrent_WhenMinimumSeedersNotReached()
{
// Arrange
var sut = _fixture.CreateSut();
SetupDeleteMock();
var downloads = new List<ITorrentItemWrapper>
{
CreateTorrentWithSeederCount("hash1", 4)
};
var rule = CreateRule("movies", TorrentPrivacyType.Public);
rule.MinSeeders = 5;
var rules = new List<ISeedingRule> { rule };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
await _fixture.ClientWrapper.DidNotReceive()
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>());
}
[Fact]
public async Task CleansTorrent_WhenMinimumSeedersReached()
{
// Arrange
var sut = _fixture.CreateSut();
SetupDeleteMock();
var downloads = new List<ITorrentItemWrapper>
{
CreateTorrentWithSeederCount("hash1", 5)
};
var rule = CreateRule("movies", TorrentPrivacyType.Public);
rule.MinSeeders = 5;
var rules = new List<ISeedingRule> { rule };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
public async Task SkipsTorrent_WhenMinimumSeedersConfiguredAndSeederCountUnavailable()
{
// Arrange
var sut = _fixture.CreateSut();
SetupDeleteMock();
var downloads = new List<ITorrentItemWrapper>
{
CreateTorrentWithSeederCount("hash1", null)
};
var rule = CreateRule("movies", TorrentPrivacyType.Public);
rule.MinSeeders = 5;
var rules = new List<ISeedingRule> { rule };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
await _fixture.ClientWrapper.DidNotReceive()
.DeleteAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<bool>());
}
[Fact]
public async Task CleansTorrent_WhenMinSeedersIsDisabled_AndSeederCountUnavailable()
{
// Arrange
var sut = _fixture.CreateSut();
SetupDeleteMock();
var downloads = new List<ITorrentItemWrapper>
{
CreateTorrentWithSeederCount("hash1", null)
};
var rule = CreateRule("movies", TorrentPrivacyType.Public);
var rules = new List<ISeedingRule> { rule };
// Act
await sut.CleanDownloadsAsync(downloads, rules);
// Assert
await _fixture.ClientWrapper.Received(1)
.DeleteAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), false);
}
[Fact]
public async Task SkipsPublicTorrent_WhenRuleIsPrivateOnly()
{
@@ -750,6 +856,51 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
}
}
public class ChangeTorrentCategoryAsync_Tests : QBitServiceDCTests
{
public ChangeTorrentCategoryAsync_Tests(QBitServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CategoryMode_SetsCategory_AndPublishes()
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false);
await _fixture.ClientWrapper.Received(1)
.SetTorrentCategoryAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "cleanuparr-dead");
await _fixture.ClientWrapper.DidNotReceive()
.AddTorrentTagAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", false);
}
[Fact]
public async Task TagMode_AddsTag_AndPublishes()
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: true);
await _fixture.ClientWrapper.Received(1)
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "cleanuparr-dead");
await _fixture.ClientWrapper.DidNotReceive()
.SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", true);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(QBitServiceFixture fixture) : base(fixture)
@@ -48,13 +48,8 @@ public class QBitServiceFixture : IDisposable
// Setup default behavior for DryRunInterceptor to execute actions directly
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
SetupSeedingRuleEvaluator();
}
@@ -114,13 +109,8 @@ public class QBitServiceFixture : IDisposable
// Re-setup default DryRunInterceptor behavior
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
SetupSeedingRuleEvaluator();
}
@@ -1,7 +1,10 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using NSubstitute;
using Newtonsoft.Json.Linq;
@@ -1128,4 +1131,218 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
public class BlockUnwantedFilesAsyncScenarios : QBitServiceTests
{
public BlockUnwantedFilesAsyncScenarios(QBitServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private static TorrentInfo MakeTorrentInfo(string hash) => new()
{
Hash = hash,
Name = "Malware Torrent",
State = TorrentState.Downloading,
DownloadSpeed = 1000,
};
private static TorrentProperties MakeTorrentProperties(bool isPrivate = false) => new()
{
AdditionalData = new Dictionary<string, JToken>
{
{ "is_private", JToken.FromObject(isPrivate) },
},
};
private void StubClient(string hash, IReadOnlyList<TorrentContent> files, bool isPrivate = false)
{
_fixture.ClientWrapper
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { MakeTorrentInfo(hash) });
_fixture.ClientWrapper
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
_fixture.ClientWrapper
.GetTorrentPropertiesAsync(hash)
.Returns(MakeTorrentProperties(isPrivate));
_fixture.ClientWrapper
.GetTorrentContentsAsync(hash)
.Returns(files);
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "all-malware-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval()
{
const string hash = "partial-malware-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 1, TorrentContentPriority.Skip);
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority()
{
const string hash = "partial-malware-any-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "clean-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
}
[Fact]
public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "already-skipped-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
[Fact]
public async Task AllFilesAlreadySkipped_NoNewMalware_DoesNotMarkForRemoval()
{
const string hash = "all-skipped-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Skip },
new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip },
]);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}
@@ -580,4 +580,18 @@ public class RTorrentItemWrapperTests
result.ShouldBeFalse();
}
}
[Fact]
public void SeederCount_ReturnsNull()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBeNull();
}
}
@@ -46,13 +46,8 @@ public class RTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public RTorrentService CreateSut(DownloadClientConfig? config = null)
@@ -108,13 +103,8 @@ public class RTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()
@@ -1,6 +1,11 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
@@ -768,4 +773,176 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
public class BlockUnwantedFilesAsyncScenarios : RTorrentServiceTests
{
public BlockUnwantedFilesAsyncScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private static RTorrentTorrent MakeDownload(string hash, bool isPrivate = false) => new()
{
Hash = hash,
Name = "Malware Torrent",
IsPrivate = isPrivate ? 1 : 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500,
};
private void StubClient(string hash, IReadOnlyList<RTorrentFile> files, bool isPrivate = false)
{
_fixture.ClientWrapper.GetTorrentAsync(hash).Returns(MakeDownload(hash, isPrivate));
_fixture.ClientWrapper.GetTrackersAsync(hash).Returns(new List<string>());
_fixture.ClientWrapper.GetTorrentFilesAsync(hash).Returns(files.ToList());
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "ALL-MALWARE-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval()
{
const string hash = "PARTIAL-MALWARE-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 1 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 1, 0);
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority()
{
const string hash = "PARTIAL-MALWARE-ANY-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 1 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "CLEAN-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "ALREADY-SKIPPED-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 0 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}
@@ -205,6 +205,94 @@ public class TransmissionItemWrapperTests
result.ShouldBe(expected);
}
[Fact]
public void SeederCount_WithTrackerStats_ReturnsMaxSeederCount()
{
// Arrange
var torrentInfo = new TorrentInfo
{
TrackerStats = new TransmissionTorrentTrackerStats[]
{
new() { SeederCount = 3L },
new() { SeederCount = 7L },
new() { SeederCount = 5L },
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBe(7);
}
[Fact]
public void SeederCount_WithNullTrackerStats_ReturnsNull()
{
// Arrange
var torrentInfo = new TorrentInfo { TrackerStats = null };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBeNull();
}
[Fact]
public void SeederCount_WithEmptyTrackerStats_ReturnsNull()
{
// Arrange
var torrentInfo = new TorrentInfo { TrackerStats = Array.Empty<TransmissionTorrentTrackerStats>() };
var wrapper = new TransmissionItemWrapper(torrentInfo);
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBeNull();
}
[Fact]
public void SeederCount_WhenAllTrackerStatsAreUnscraped_ReturnsNull()
{
// Transmission RPC reports seederCount = -1 for trackers that have not been scraped yet
// (libtransmission/announcer.cc: view.seederCount = tracker.seeder_count().value_or(-1)).
var torrentInfo = new TorrentInfo
{
TrackerStats = new TransmissionTorrentTrackerStats[]
{
new() { SeederCount = -1L },
new() { SeederCount = -1L },
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var result = wrapper.SeederCount;
result.ShouldBeNull();
}
[Fact]
public void SeederCount_WithMixedScrapedAndUnscrapedTrackerStats_ReturnsMaxScrapedValue()
{
var torrentInfo = new TorrentInfo
{
TrackerStats = new TransmissionTorrentTrackerStats[]
{
new() { SeederCount = -1L },
new() { SeederCount = 4L },
}
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
var result = wrapper.SeederCount;
result.ShouldBe(4);
}
// TrackerDomains property tests
[Fact]
public void TrackerDomains_WithMultipleTrackers_ReturnsExtractedDomains()
@@ -1,6 +1,7 @@
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using NSubstitute;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
using Shouldly;
using Xunit;
@@ -364,6 +365,50 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public void ExcludesAlreadyLabeled_WhenUseTag()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies", Labels = ["unlinked"] }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/movies", Labels = [] })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads,
new UnlinkedConfig { Categories = ["movies"], TargetCategory = "unlinked", UseTag = true });
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
}
[Fact]
public void ExcludesAlreadyLabeled_WhenUseTag_CaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies", Labels = ["UNLINKED"] }),
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/movies", Labels = [] })
};
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads,
new UnlinkedConfig { Categories = ["movies"], TargetCategory = "unlinked", UseTag = true });
// Assert
result.ShouldNotBeNull();
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("hash2");
}
}
public class CreateCategoryAsync_Tests : TransmissionServiceDCTests
@@ -671,6 +716,92 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
}
[Fact]
public async Task UseTag_SetsLabel_AndDoesNotChangeLocation()
{
// Arrange
var sut = _fixture.CreateSut();
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked",
UseTag = true
};
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = Path.Combine("downloads", "movies"),
Labels = ["existing"],
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
await _fixture.ClientWrapper.Received(1)
.TorrentSetAsync(Arg.Is<TorrentSettings>(s =>
s.Ids.Contains(123L)
&& s.Labels.Contains("existing")
&& s.Labels.Contains("unlinked")));
await _fixture.ClientWrapper.DidNotReceive()
.TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
public async Task UseTag_DoesNotDuplicateLabel_WhenAlreadyPresentWithDifferentCase()
{
// Arrange
var sut = _fixture.CreateSut();
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked",
UseTag = true
};
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = Path.Combine("downloads", "movies"),
Labels = ["UNLINKED"],
Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } },
FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } }
})
};
_fixture.HardLinkFileService
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
await _fixture.ClientWrapper.Received(1)
.TorrentSetAsync(Arg.Is<TorrentSettings>(s =>
s.Ids.Contains(123L)
&& s.Labels.Length == 1
&& s.Labels.Contains("UNLINKED")));
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
@@ -45,13 +45,8 @@ public class TransmissionServiceFixture : IDisposable
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public TransmissionService CreateSut(DownloadClientConfig? config = null)
@@ -107,13 +102,8 @@ public class TransmissionServiceFixture : IDisposable
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()
@@ -1,7 +1,12 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
using Shouldly;
using Xunit;
@@ -30,26 +35,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns((TransmissionTorrents?)null);
@@ -82,26 +67,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -141,26 +106,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -211,26 +156,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -268,26 +193,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -333,26 +238,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -386,26 +271,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -450,26 +315,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -516,26 +361,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -572,26 +397,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -635,26 +440,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -694,26 +479,6 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
Torrents = new[] { torrentInfo }
};
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE,
TorrentFields.LABELS
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(torrents);
@@ -768,4 +533,163 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
result.ChangeCategory.ShouldBeTrue();
}
}
public class BlockUnwantedFilesAsyncScenarios : TransmissionServiceTests
{
public BlockUnwantedFilesAsyncScenarios(TransmissionServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private void StubClient(string hash, (string Name, bool Wanted)[] files, bool isPrivate = false)
{
TorrentInfo torrentInfo = new()
{
Id = 42,
HashString = hash,
Name = "Malware Torrent",
Status = 4,
IsPrivate = isPrivate,
Files = files.Select(f => new TransmissionTorrentFiles { Name = f.Name }).ToArray(),
FileStats = files.Select(f => new TransmissionTorrentFileStats { Wanted = f.Wanted }).ToArray(),
};
_fixture.ClientWrapper
.TorrentGetAsync(Arg.Any<string[]>(), hash)
.Returns(new TransmissionTorrents { Torrents = new[] { torrentInfo } });
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "all-malware-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("malware.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsTorrentSet_AndDoesNotMarkForRemoval()
{
const string hash = "partial-malware-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("movie.mkv", true), ("installer.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsTorrentSet()
{
const string hash = "partial-malware-any-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash, [("movie.mkv", true), ("installer.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "clean-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("movie.mkv", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task AlreadyUnwantedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "already-skipped-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash, [("movie.mkv", true), ("installer.exe", false)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}
@@ -278,4 +278,18 @@ public class UTorrentItemWrapperTests
// Assert
result.ShouldBeFalse();
}
[Fact]
public void SeederCount_ReturnsSeedsInSwarm()
{
// Arrange
var torrentItem = new UTorrentItem { SeedsInSwarm = 15 };
var wrapper = new UTorrentItemWrapper(torrentItem, new UTorrentProperties());
// Act
var result = wrapper.SeederCount;
// Assert
result.ShouldBe(15);
}
}
@@ -45,13 +45,8 @@ public class UTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public UTorrentService CreateSut(DownloadClientConfig? config = null)
@@ -107,13 +102,8 @@ public class UTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()
@@ -1,6 +1,11 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
@@ -711,4 +716,178 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
result.ChangeCategory.ShouldBeTrue();
}
}
public class BlockUnwantedFilesAsyncScenarios : UTorrentServiceTests
{
public BlockUnwantedFilesAsyncScenarios(UTorrentServiceFixture fixture) : base(fixture)
{
}
private void SetMalwareBlockerContext(ContentBlockerConfig? config = null)
{
ContextProvider.Set(config ?? new ContentBlockerConfig());
ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr);
_fixture.BlocklistProvider
.GetBlocklistType(Arg.Any<InstanceType>())
.Returns(BlocklistType.Blacklist);
_fixture.BlocklistProvider
.GetPatterns(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<string>());
_fixture.BlocklistProvider
.GetRegexes(Arg.Any<InstanceType>())
.Returns(new ConcurrentBag<Regex>());
}
private void StubClient(string hash, IReadOnlyList<UTorrentFile> files, bool isPrivate = false)
{
UTorrentItem item = new()
{
Hash = hash,
Name = "Malware Torrent",
Status = 9,
DownloadSpeed = 1000,
};
UTorrentProperties properties = new()
{
Hash = hash,
Pex = isPrivate ? -1 : 0,
Trackers = string.Empty,
};
_fixture.ClientWrapper.GetTorrentAsync(hash).Returns(item);
_fixture.ClientWrapper.GetTorrentPropertiesAsync(hash).Returns(properties);
_fixture.ClientWrapper.GetTorrentFilesAsync(hash).Returns(files.ToList());
}
[Fact]
public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason()
{
const string hash = "all-malware-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task PartialMalware_CallsSetFilesPriority_AndDoesNotMarkForRemoval()
{
const string hash = "partial-malware-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.Received(1)
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(idx => idx.Count == 1 && idx[0] == 1), 0);
}
[Fact]
public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilesPriority()
{
const string hash = "partial-malware-any-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotMarkForRemoval()
{
const string hash = "clean-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked()
{
const string hash = "already-skipped-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 0, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
}
}
Loaded 100 of 346 files, more files were not shown because too many files have changed in this diff. Show more