mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 20:08:59 -04:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8c5f23cf | ||
|
|
9eebeed990 | ||
|
|
a79a60a339 | ||
|
|
d1bd9fddcc | ||
|
|
96823adcc3 | ||
|
|
e0e88147aa | ||
|
|
f278a0dad0 | ||
|
|
f61300b869 | ||
|
|
561c05778c | ||
|
|
60d273991d | ||
|
|
ddb1042ca5 | ||
|
|
9a31e86ad8 | ||
|
|
614e97313e | ||
|
|
a34a3d3c7e | ||
|
|
f9588d89c0 | ||
|
|
eacd9346a5 | ||
|
|
0561c64ddf | ||
|
|
304a8e78ee | ||
|
|
e008b64a1d | ||
|
|
4f7e2d33b4 | ||
|
|
b1b19e5f29 | ||
|
|
40ab0e9fad | ||
|
|
fa1801875e | ||
|
|
c6ef6ad979 | ||
|
|
74f11f5beb | ||
|
|
c0950537ab | ||
|
|
7cc079c61b | ||
|
|
7aa3224f4d | ||
|
|
1cc068c2ab | ||
|
|
28f22f1085 | ||
|
|
084f83efca | ||
|
|
26b76908eb | ||
|
|
ffc8a0a39a | ||
|
|
8ccd93dc97 | ||
|
|
1ca935b62b |
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+149
@@ -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)]
|
||||
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
+89
-75
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -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",
|
||||
|
||||
-5
@@ -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();
|
||||
|
||||
+14
@@ -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; } = [];
|
||||
}
|
||||
+21
@@ -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; }
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
-4
@@ -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; }
|
||||
}
|
||||
+25
@@ -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,
|
||||
};
|
||||
}
|
||||
+22
@@ -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; }
|
||||
}
|
||||
+28
@@ -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,
|
||||
};
|
||||
}
|
||||
+50
@@ -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,
|
||||
};
|
||||
}
|
||||
+25
@@ -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,
|
||||
};
|
||||
}
|
||||
+108
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-46
@@ -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();
|
||||
|
||||
+120
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-76
@@ -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
|
||||
|
||||
+6
-27
@@ -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
|
||||
{
|
||||
|
||||
+6
@@ -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,
|
||||
};
|
||||
}
|
||||
+6
@@ -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,
|
||||
};
|
||||
}
|
||||
+8
-20
@@ -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();
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+5
-10
@@ -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))
|
||||
{
|
||||
|
||||
+70
-224
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-11
@@ -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();
|
||||
|
||||
+15
-95
@@ -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();
|
||||
|
||||
+2
-2
@@ -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; }
|
||||
}
|
||||
+1
-1
@@ -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; }
|
||||
}
|
||||
+1
-1
@@ -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; }
|
||||
}
|
||||
+3
-3
@@ -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; }
|
||||
}
|
||||
+2
-2
@@ -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; }
|
||||
|
||||
+1
-1
@@ -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; }
|
||||
|
||||
|
||||
+4
-4
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
+3
-15
@@ -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
|
||||
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-14
@@ -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()
|
||||
|
||||
+154
@@ -1,7 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -542,4 +546,154 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
result.ChangeCategory.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
public class BlockUnwantedFilesAsyncScenarios : DelugeServiceTests
|
||||
{
|
||||
public BlockUnwantedFilesAsyncScenarios(DelugeServiceFixture 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 DownloadStatus MakeDownloadStatus(string hash) => new()
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Malware Torrent",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task AllFilesAreMalware_DoesNotCallChangeFilesPriority_AndMarksForRemoval()
|
||||
{
|
||||
const string hash = "all-malware-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
SetMalwareBlockerContext();
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(MakeDownloadStatus(hash));
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "malware.exe" } },
|
||||
},
|
||||
});
|
||||
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
|
||||
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PartialMalware_CallsChangeFilesPriority_AndDoesNotMarkForRemoval()
|
||||
{
|
||||
const string hash = "partial-malware-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
SetMalwareBlockerContext();
|
||||
|
||||
_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);
|
||||
|
||||
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.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>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -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)
|
||||
|
||||
+4
-14
@@ -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();
|
||||
}
|
||||
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-14
@@ -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()
|
||||
|
||||
+177
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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()
|
||||
|
||||
+131
@@ -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()
|
||||
{
|
||||
|
||||
+4
-14
@@ -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()
|
||||
|
||||
+164
-240
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-14
@@ -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()
|
||||
|
||||
+179
@@ -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
Reference in new issue
Block a user