mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 11:59:02 -04:00
Compare commits
50
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 | ||
|
|
90a4909e57 | ||
|
|
ef8fb2dd0b | ||
|
|
20ad056400 | ||
|
|
ab792f5fad | ||
|
|
48c36fab8f | ||
|
|
3553fce597 | ||
|
|
c1d2790c8d | ||
|
|
18a9c66ce7 | ||
|
|
7ec60c7ea0 | ||
|
|
a96bc36a1e | ||
|
|
b575644d5f | ||
|
|
13bc71c3cd | ||
|
|
c3f3ee880d | ||
|
|
8ab4a55595 | ||
|
|
85de80a463 |
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/
|
||||
|
||||
@@ -4,6 +4,13 @@ Thanks for your interest in contributing to Cleanuparr! This guide will help you
|
||||
|
||||
## Before You Start
|
||||
|
||||
### AI usage
|
||||
|
||||
In this ever-evolving field of work, AI is now the shiny new tool to help programmers work faster and there's nothing wrong with that.
|
||||
But it is **very** wrong to rely solely on AI tools to write, review and test your code.
|
||||
|
||||
**If you do not have a background in programming and you do not intend to test your code properly, please do not submit AI-generated code.** If you still want to help in other ways such as testing features, that would also help a lot!
|
||||
|
||||
### Announce Your Intent
|
||||
|
||||
Before starting any work, please let us know what you want to contribute:
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<div align="center">
|
||||
|
||||
_Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||
_/kliː.nʌp.ər/ — like "cleanuper", someone who does the cleanup. Not "CleanupArr" or "CleanUpArr"._
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
@@ -9,7 +13,9 @@ _Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
|
||||
</div>
|
||||
|
||||
Cleanuparr is an advanced download manager for the Servarr ecosystem. It works with Sonarr, Radarr, Lidarr, Readarr, and Whisparr alongside download clients like qBittorrent, Transmission, and Deluge. Beyond cleaning up stalled, blocked, and malicious downloads, it searches for missing content and quality upgrades, manages seeding, and removes orphaned files.
|
||||
|
||||
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
@@ -28,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.
|
||||
|
||||
@@ -46,6 +53,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr v2**
|
||||
- **Whisparr v3**
|
||||
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
@@ -77,6 +85,8 @@ docker run -d --name cleanuparr \
|
||||
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
|
||||
|
||||
> Prefer not to self-host? A managed Cleanuparr instance is available via [ElfHosted](https://store.elfhosted.com/product-category/personal-stacks/?utm_source=github&utm_medium=readme&utm_campaign=cleanuparr-readme), bundled alongside Sonarr/Radarr to keep your queues tidy (7-day trial).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
After installation, open your browser and navigate to:
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Middleware;
|
||||
|
||||
public class TrustedForwardedHeadersMiddlewareTests
|
||||
{
|
||||
private static HttpContext NewContext(IPAddress peer, Action<HttpContext>? configure = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Connection.RemoteIpAddress = peer;
|
||||
ctx.Request.Scheme = "http";
|
||||
ctx.Request.Host = new HostString("backend.local");
|
||||
configure?.Invoke(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrusted_direct_peer_leaves_everything_alone()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "spoofed.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.1"));
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
ctx.Request.Host.Value.ShouldBe("backend.local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Local_peer_no_xff_is_a_no_op()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback);
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spoofed_local_xff_with_appended_attacker_promotes_attacker_ip_only()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, 99.99.99.99");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_xff_entry_from_overwrite_mode_proxy_becomes_client_ip()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legitimate_lan_client_through_local_proxy_resolves_to_lan_ip()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "192.168.1.50");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("192.168.1.50"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_trusted_network_pops_through_to_real_client()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "100.64.1.5, 100.64.0.7");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string> { "100.64.0.0/10" });
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("100.64.1.5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_and_host_applied_when_chain_consumed()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("https");
|
||||
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_not_applied_when_peer_untrusted()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c => c.Request.Headers["X-Forwarded-Proto"] = "https");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X_real_ip_is_ignored()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Real-IP"] = "10.0.0.5");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_xff_entry_fails_closed()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, not-an-ip";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_entries_in_xff_are_skipped()
|
||||
{
|
||||
// nginx with `proxy_set_header X-Forwarded-For "$http_x_forwarded_for, 1.2.3.4"`
|
||||
// produces a leading empty entry when the input header was absent.
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = ", 99.99.99.99");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ipv4_mapped_ipv6_loopback_is_treated_as_trusted()
|
||||
{
|
||||
// Kestrel may surface "::ffff:127.0.0.1" as the peer.
|
||||
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
|
||||
var ctx = NewContext(mapped, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_with_multiple_values_uses_only_first_token()
|
||||
{
|
||||
// Chained proxies that append (rather than overwrite) X-Forwarded-Proto
|
||||
// produce comma-separated values like "https, http". Only the leftmost
|
||||
// hop's value should be applied — matching how XFF is handled.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https, http";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("https");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_with_unknown_scheme_is_ignored()
|
||||
{
|
||||
// Anything outside the http/https allowlist is dropped to keep
|
||||
// arbitrary values (e.g. "javascript:") from flowing into URLs.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "javascript:";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_host_with_multiple_values_uses_only_first_token()
|
||||
{
|
||||
// Same multi-hop concern as X-Forwarded-Proto — the host string must
|
||||
// not end up as "a.example, b.example".
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com, attacker.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_entry_mid_chain_does_not_partially_mutate_remote_ip()
|
||||
{
|
||||
// Walk right-to-left: 10.0.0.5 (trusted) is popped first, then
|
||||
// "not-an-ip" fails. The pre-fix middleware committed mutation eagerly,
|
||||
// leaving RemoteIpAddress = 10.0.0.5. Fix: validate-then-commit, so the
|
||||
// original peer is preserved when any chain entry is malformed.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
c.Request.Headers["X-Forwarded-For"] = "not-an-ip, 10.0.0.5");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,22 @@ public static class TrustedNetworkAuthenticationDefaults
|
||||
|
||||
public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public TrustedNetworkAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
UrlEncoder encoder,
|
||||
DataContext dataContext)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Load auth config from database
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
var config = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (config is null || !config.Auth.DisableAuthForLocalAddresses)
|
||||
{
|
||||
@@ -36,7 +39,7 @@ public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<Authent
|
||||
}
|
||||
|
||||
// Determine client IP
|
||||
var clientIp = GetClientIp(config.Auth.TrustForwardedHeaders);
|
||||
var clientIp = ResolveClientIp(Context);
|
||||
if (clientIp is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
@@ -73,42 +76,13 @@ public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<Authent
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
private IPAddress? GetClientIp(bool trustForwardedHeaders) =>
|
||||
ResolveClientIp(Context, trustForwardedHeaders);
|
||||
|
||||
public static IPAddress? ResolveClientIp(HttpContext httpContext, bool trustForwardedHeaders)
|
||||
{
|
||||
var remoteIp = httpContext.Connection.RemoteIpAddress;
|
||||
if (remoteIp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only trust forwarded headers if the direct connection is from a local address
|
||||
if (trustForwardedHeaders && remoteIp.IsLocalAddress())
|
||||
{
|
||||
// Check X-Forwarded-For first, then X-Real-IP
|
||||
var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedFor))
|
||||
{
|
||||
// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
|
||||
// The first one is the original client
|
||||
var firstIp = forwardedFor.Split(',')[0].Trim();
|
||||
if (IPAddress.TryParse(firstIp, out var parsedIp))
|
||||
{
|
||||
return parsedIp;
|
||||
}
|
||||
}
|
||||
|
||||
var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out var realParsedIp))
|
||||
{
|
||||
return realParsedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return remoteIp;
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns the connection's remote IP address. Callers must run <see cref="Cleanuparr.Api.Middleware.TrustedForwardedHeadersMiddleware"/>
|
||||
/// earlier in the pipeline so that <c>X-Forwarded-*</c> headers from trusted proxy chains have already been resolved into <c>Connection.RemoteIpAddress</c>.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current HTTP context.</param>
|
||||
/// <returns>The resolved client IP, or <c>null</c> when unavailable.</returns>
|
||||
public static IPAddress? ResolveClientIp(HttpContext httpContext) => httpContext.Connection.RemoteIpAddress;
|
||||
|
||||
public static bool IsTrustedAddress(IPAddress clientIp, List<string> trustedNetworks)
|
||||
{
|
||||
|
||||
@@ -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,29 +56,53 @@ 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>();
|
||||
|
||||
// Block non-auth requests until setup is complete
|
||||
app.UseMiddleware<SetupGuardMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseCors("DevSpa");
|
||||
}
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
|
||||
@@ -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,5 +1,3 @@
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
namespace Cleanuparr.Api.Extensions;
|
||||
|
||||
public static class HttpRequestExtensions
|
||||
@@ -19,26 +17,13 @@ public static class HttpRequestExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the external base URL (scheme + host + basePath), respecting
|
||||
/// X-Forwarded-Proto and X-Forwarded-Host headers when the connection
|
||||
/// originates from a local address.
|
||||
/// Returns the external base URL (scheme + host + basePath).
|
||||
/// TrustedForwardedHeadersMiddleware has already applied X-Forwarded-Proto and X-Forwarded-Host to <see cref="HttpRequest.Scheme"/> / <see cref="HttpRequest.Host"/>.
|
||||
/// </summary>
|
||||
public static string GetExternalBaseUrl(this HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
var scheme = request.Scheme;
|
||||
var host = request.Host.ToString();
|
||||
var remoteIp = context.Connection.RemoteIpAddress;
|
||||
|
||||
// Trust forwarded headers only from local connections
|
||||
// (consistent with TrustedNetworkAuthenticationHandler)
|
||||
if (remoteIp is not null && remoteIp.IsLocalAddress())
|
||||
{
|
||||
scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? scheme;
|
||||
host = request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? host;
|
||||
}
|
||||
|
||||
var basePath = request.GetSafeBasePath();
|
||||
return $"{scheme}://{host}{basePath}";
|
||||
return $"{request.Scheme}://{request.Host}{basePath}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -57,8 +58,7 @@ public sealed class AuthController : ControllerBase
|
||||
var generalConfig = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
|
||||
{
|
||||
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
|
||||
HttpContext, generalConfig.Auth.TrustForwardedHeaders);
|
||||
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(HttpContext);
|
||||
if (clientIp is not null)
|
||||
{
|
||||
authBypass = TrustedNetworkAuthenticationHandler.IsTrustedAddress(
|
||||
@@ -93,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
|
||||
@@ -105,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);
|
||||
@@ -134,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
|
||||
@@ -151,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);
|
||||
@@ -191,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);
|
||||
@@ -232,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);
|
||||
@@ -259,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();
|
||||
@@ -271,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
|
||||
@@ -321,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
|
||||
@@ -336,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;
|
||||
@@ -352,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));
|
||||
@@ -370,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);
|
||||
@@ -403,7 +404,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (storedToken is not null)
|
||||
{
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -421,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();
|
||||
@@ -456,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}",
|
||||
@@ -487,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();
|
||||
@@ -510,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);
|
||||
@@ -531,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
|
||||
@@ -559,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);
|
||||
@@ -572,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,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
|
||||
@@ -672,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();
|
||||
@@ -696,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);
|
||||
@@ -719,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,4 +24,6 @@ public abstract record QueueRuleDto
|
||||
public ushort MaxCompletionPercentage { get; set; }
|
||||
|
||||
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
|
||||
|
||||
public bool ChangeCategory { get; set; } = false;
|
||||
}
|
||||
-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();
|
||||
|
||||
+19
-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
|
||||
@@ -88,6 +78,7 @@ public class QueueRulesController : ControllerBase
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
ChangeCategory = ruleDto.ChangeCategory,
|
||||
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
|
||||
};
|
||||
|
||||
@@ -96,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();
|
||||
@@ -108,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();
|
||||
@@ -127,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
|
||||
{
|
||||
@@ -140,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
|
||||
@@ -161,6 +137,7 @@ public class QueueRulesController : ControllerBase
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
ChangeCategory = ruleDto.ChangeCategory,
|
||||
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
|
||||
};
|
||||
|
||||
@@ -171,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();
|
||||
@@ -183,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();
|
||||
@@ -210,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);
|
||||
@@ -220,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();
|
||||
@@ -245,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();
|
||||
@@ -259,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
|
||||
{
|
||||
@@ -275,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
|
||||
@@ -293,6 +245,7 @@ public class QueueRulesController : ControllerBase
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
ChangeCategory = ruleDto.ChangeCategory,
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.SlowRules.ToListAsync();
|
||||
@@ -300,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();
|
||||
@@ -312,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();
|
||||
@@ -331,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
|
||||
{
|
||||
@@ -344,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
|
||||
@@ -368,6 +306,7 @@ public class QueueRulesController : ControllerBase
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
ChangeCategory = ruleDto.ChangeCategory,
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.SlowRules
|
||||
@@ -377,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();
|
||||
@@ -389,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();
|
||||
@@ -416,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);
|
||||
@@ -426,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors ASP.NET Core's <see cref="Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware"/> but reads the trusted-network set from
|
||||
/// <c>AuthConfig</c> dynamically per request, so admins can toggle <c>TrustForwardedHeaders</c> / <c>TrustedNetworks</c> without restarting the app.
|
||||
/// Walks <c>X-Forwarded-For</c> right-to-left, popping entries contributed by trusted hops, and stops at the first untrusted entry
|
||||
/// which becomes the new <see cref="HttpContext.Connection"/>.<see cref="ConnectionInfo.RemoteIpAddress"/>.
|
||||
/// </summary>
|
||||
public class TrustedForwardedHeadersMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public TrustedForwardedHeadersMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var trustedNetworks = await LoadTrustedNetworksAsync();
|
||||
if (trustedNetworks is not null)
|
||||
{
|
||||
ApplyForwardedHeaders(context, trustedNetworks);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static async Task<List<string>?> LoadTrustedNetworksAsync()
|
||||
{
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (config is null || !config.Auth.TrustForwardedHeaders)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return config.Auth.TrustedNetworks;
|
||||
}
|
||||
|
||||
public static void ApplyForwardedHeaders(HttpContext context, List<string> trustedNetworks)
|
||||
{
|
||||
var peer = context.Connection.RemoteIpAddress;
|
||||
if (peer is null || !IsTrustedHop(peer, trustedNetworks))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(forwardedFor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = forwardedFor.Split(
|
||||
',',
|
||||
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk right-to-left, staging the resolved IP locally
|
||||
// A malformed entry mid-chain leaves the original peer in place rather than landing on a partial mutation
|
||||
var resolvedRemoteIp = peer;
|
||||
var consumedAtLeastOne = false;
|
||||
for (var i = entries.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (!IPAddress.TryParse(entries[i], out var entryIp))
|
||||
{
|
||||
// Malformed entry
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedRemoteIp = entryIp;
|
||||
consumedAtLeastOne = true;
|
||||
|
||||
if (!IsTrustedHop(entryIp, trustedNetworks))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!consumedAtLeastOne)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Connection.RemoteIpAddress = resolvedRemoteIp;
|
||||
|
||||
// X-Forwarded-Proto / X-Forwarded-Host are also comma-separated lists in multi-hop chains
|
||||
var forwardedProto = context.Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedProto))
|
||||
{
|
||||
var scheme = forwardedProto.Split(',')[0].Trim();
|
||||
if (scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ||
|
||||
scheme.Equals("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Request.Scheme = scheme;
|
||||
}
|
||||
}
|
||||
|
||||
var forwardedHost = context.Request.Headers["X-Forwarded-Host"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedHost))
|
||||
{
|
||||
var host = forwardedHost.Split(',')[0].Trim();
|
||||
if (!string.IsNullOrEmpty(host))
|
||||
{
|
||||
context.Request.Host = new HostString(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTrustedHop(IPAddress address, List<string> trustedNetworks)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (address.IsLocalAddress())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var cidr in trustedNetworks)
|
||||
{
|
||||
if (TrustedNetworkAuthenticationHandler.MatchesCidr(address, cidr))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -80,19 +80,21 @@ builder.Services
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "DataProtection-Keys")))
|
||||
.SetApplicationName("Cleanuparr");
|
||||
|
||||
// Add CORS before SignalR
|
||||
builder.Services.AddCors(options =>
|
||||
// CORS is needed only for development
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
options.AddPolicy("Any", policy =>
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
policy
|
||||
// https://github.com/dotnet/aspnetcore/issues/4457#issuecomment-465669576
|
||||
.SetIsOriginAllowed(_ => true)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials(); // Required for SignalR auth
|
||||
options.AddPolicy("DevSpa", policy =>
|
||||
{
|
||||
policy
|
||||
.WithOrigins("http://localhost:4200", "http://127.0.0.1:4200")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
using Newtonsoft.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
public sealed record DownloadStatus
|
||||
{
|
||||
public string? Hash { get; init; }
|
||||
|
||||
public string? State { get; init; }
|
||||
|
||||
|
||||
public DelugeState State { get; init; }
|
||||
|
||||
public string? Name { get; init; }
|
||||
|
||||
|
||||
public ulong Eta { get; init; }
|
||||
|
||||
|
||||
[JsonProperty("download_payload_rate")]
|
||||
public long DownloadSpeed { get; init; }
|
||||
|
||||
|
||||
public bool Private { get; init; }
|
||||
|
||||
|
||||
[JsonProperty("total_size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
|
||||
[JsonProperty("total_done")]
|
||||
public long TotalDone { get; init; }
|
||||
|
||||
[JsonProperty("is_finished")]
|
||||
public bool IsFinished { get; init; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
@@ -29,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; }
|
||||
|
||||
@@ -39,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,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Torrent state values reported by Deluge
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(DelugeStateConverter))]
|
||||
public enum DelugeState
|
||||
{
|
||||
Unknown = 0,
|
||||
Allocating,
|
||||
Checking,
|
||||
Downloading,
|
||||
Seeding,
|
||||
Paused,
|
||||
Error,
|
||||
Queued,
|
||||
Moving,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Maps Deluge wire state strings to <see cref="DelugeState"/>, falling back to <see cref="DelugeState.Unknown"/> for any value not present in the enum
|
||||
/// </summary>
|
||||
public sealed class DelugeStateConverter : JsonConverter<DelugeState>
|
||||
{
|
||||
public override DelugeState ReadJson(JsonReader reader, Type objectType, DelugeState existingValue, bool hasExistingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.TokenType != JsonToken.String || reader.Value is not string raw)
|
||||
{
|
||||
return DelugeState.Unknown;
|
||||
}
|
||||
|
||||
return Enum.TryParse<DelugeState>(raw, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: DelugeState.Unknown;
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, DelugeState value, JsonSerializer serializer)
|
||||
{
|
||||
writer.WriteValue(value.ToString());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+33
-15
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -399,13 +400,11 @@ public class DelugeItemWrapperTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Downloading", true)]
|
||||
[InlineData("downloading", true)]
|
||||
[InlineData("DOWNLOADING", true)]
|
||||
[InlineData("Seeding", false)]
|
||||
[InlineData("Paused", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsDownloading_ReturnsCorrectValue(string? state, bool expected)
|
||||
[InlineData(DelugeState.Downloading, true)]
|
||||
[InlineData(DelugeState.Seeding, false)]
|
||||
[InlineData(DelugeState.Paused, false)]
|
||||
[InlineData(DelugeState.Unknown, false)]
|
||||
public void IsDownloading_ReturnsCorrectValue(DelugeState state, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
@@ -424,14 +423,14 @@ public class DelugeItemWrapperTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Downloading", 0, 0UL, true)] // Downloading with no speed and no ETA = stalled
|
||||
[InlineData("Downloading", 1000, 0UL, false)] // Has download speed = not stalled
|
||||
[InlineData("Downloading", 0, 100UL, false)] // Has ETA = not stalled
|
||||
[InlineData("Downloading", 1000, 100UL, false)] // Has both = not stalled
|
||||
[InlineData("Seeding", 0, 0UL, false)] // Not downloading state = not stalled
|
||||
[InlineData("Paused", 0, 0UL, false)] // Not downloading state = not stalled
|
||||
[InlineData(null, 0, 0UL, false)] // Null state = not stalled
|
||||
public void IsStalled_ReturnsCorrectValue(string? state, long downloadSpeed, ulong eta, bool expected)
|
||||
[InlineData(DelugeState.Downloading, 0, 0UL, true)]
|
||||
[InlineData(DelugeState.Downloading, 1000, 0UL, false)]
|
||||
[InlineData(DelugeState.Downloading, 0, 100UL, false)]
|
||||
[InlineData(DelugeState.Downloading, 1000, 100UL, false)]
|
||||
[InlineData(DelugeState.Seeding, 0, 0UL, false)]
|
||||
[InlineData(DelugeState.Paused, 0, 0UL, false)]
|
||||
[InlineData(DelugeState.Unknown, 0, 0UL, false)]
|
||||
public void IsStalled_ReturnsCorrectValue(DelugeState state, long downloadSpeed, ulong eta, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var downloadStatus = new DownloadStatus
|
||||
@@ -450,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);
|
||||
}
|
||||
}
|
||||
+118
-28
@@ -34,9 +34,9 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "Downloading", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = DelugeState.Downloading, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash3", Name = "Torrent 3", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
@@ -51,29 +51,6 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
foreach (var item in result) { item.Hash.ShouldNotBeNull(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Torrent 1", State = "SEEDING", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash2", Name = "Torrent 2", State = "seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsEmptyList_WhenNull()
|
||||
{
|
||||
@@ -99,8 +76,121 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "", Name = "No Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = "Seeding", Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
new DownloadStatus { Hash = "", Name = "No Hash", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" },
|
||||
new DownloadStatus { Hash = "hash1", Name = "Valid Hash", State = DelugeState.Seeding, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncludesPausedFinishedTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Paused finished", State = DelugeState.Paused, IsFinished = true, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncludesQueuedFinishedTorrents()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Queued finished", State = DelugeState.Queued, IsFinished = true, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
result.ShouldHaveSingleItem();
|
||||
result[0].Hash.ShouldBe("hash1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExcludesPausedNotFinished()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Paused mid-download", State = DelugeState.Paused, IsFinished = false, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExcludesQueuedNotFinished()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Queued mid-download", State = DelugeState.Queued, IsFinished = false, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetStatusForAllTorrents()
|
||||
.Returns(downloads);
|
||||
|
||||
// Act
|
||||
var result = await sut.GetSeedingDownloads();
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncludesSeedingRegardlessOfIsFinished()
|
||||
{
|
||||
// Arrange
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloads = new List<DownloadStatus>
|
||||
{
|
||||
new DownloadStatus { Hash = "hash1", Name = "Seeding without IsFinished flag", State = DelugeState.Seeding, IsFinished = false, Private = false, Trackers = new List<Tracker>(), DownloadLocation = "/downloads" }
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
|
||||
+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()
|
||||
|
||||
+220
-21
@@ -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;
|
||||
@@ -51,7 +55,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = true,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -74,11 +78,11 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
@@ -96,7 +100,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -119,11 +123,11 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
@@ -148,7 +152,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -187,7 +191,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -211,11 +215,11 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
@@ -239,7 +243,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -267,7 +271,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Label = category,
|
||||
@@ -296,7 +300,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>
|
||||
@@ -333,7 +337,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Seeding",
|
||||
State = DelugeState.Seeding,
|
||||
Private = false,
|
||||
DownloadSpeed = 0,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -356,7 +360,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
@@ -374,7 +378,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 0,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -397,7 +401,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((false, DeleteReason.None, false));
|
||||
.Returns((false, DeleteReason.None, false, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
@@ -422,7 +426,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
@@ -445,13 +449,14 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((true, DeleteReason.SlowSpeed, true));
|
||||
.Returns((true, DeleteReason.SlowSpeed, true, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
result.ChangeCategory.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -464,7 +469,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = "Downloading",
|
||||
State = DelugeState.Downloading,
|
||||
DownloadSpeed = 0,
|
||||
Eta = 0,
|
||||
Private = false,
|
||||
@@ -488,13 +493,207 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((true, DeleteReason.Stalled, true));
|
||||
.Returns((true, DeleteReason.Stalled, true, false));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
result.DeleteFromClient.ShouldBeTrue();
|
||||
result.ChangeCategory.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag()
|
||||
{
|
||||
const string hash = "test-hash";
|
||||
var sut = _fixture.CreateSut();
|
||||
|
||||
var downloadStatus = new DownloadStatus
|
||||
{
|
||||
Hash = hash,
|
||||
Name = "Test Torrent",
|
||||
State = DelugeState.Downloading,
|
||||
Private = false,
|
||||
DownloadSpeed = 1000,
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
};
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentStatus(hash)
|
||||
.Returns(downloadStatus);
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentFiles(hash)
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
|
||||
}
|
||||
});
|
||||
|
||||
_fixture.RuleEvaluator
|
||||
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
|
||||
.Returns((true, DeleteReason.SlowSpeed, false, true));
|
||||
|
||||
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
|
||||
result.DeleteFromClient.ShouldBeFalse();
|
||||
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>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Newtonsoft.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
|
||||
|
||||
public class DownloadStatusDeserializationTests
|
||||
{
|
||||
private const string BaseDelugePayload = """
|
||||
{
|
||||
"hash": "abc123",
|
||||
"name": "Some Torrent",
|
||||
"eta": 0,
|
||||
"private": false,
|
||||
"total_size": 1024,
|
||||
"total_done": 1024,
|
||||
"is_finished": true,
|
||||
"label": "movies",
|
||||
"seeding_time": 0,
|
||||
"ratio": 1.0,
|
||||
"trackers": [],
|
||||
"download_payload_rate": 0,
|
||||
"download_location": "/downloads"
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void UnknownStateValue_DeserializesToUnknown_AndPreservesOtherFields()
|
||||
{
|
||||
var json = BaseDelugePayload.Replace("\"hash\": \"abc123\",", "\"hash\": \"abc123\", \"state\": \"FutureNewState\",");
|
||||
|
||||
var result = JsonConvert.DeserializeObject<DownloadStatus>(json);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.State.ShouldBe(DelugeState.Unknown);
|
||||
result.Hash.ShouldBe("abc123");
|
||||
result.IsFinished.ShouldBeTrue();
|
||||
result.Size.ShouldBe(1024L);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Seeding")]
|
||||
[InlineData("seeding")]
|
||||
[InlineData("SEEDING")]
|
||||
public void KnownState_DeserializesCaseInsensitively(string wireValue)
|
||||
{
|
||||
var json = BaseDelugePayload.Replace("\"hash\": \"abc123\",", $"\"hash\": \"abc123\", \"state\": \"{wireValue}\",");
|
||||
|
||||
var result = JsonConvert.DeserializeObject<DownloadStatus>(json);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.State.ShouldBe(DelugeState.Seeding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingStateField_DeserializesToUnknown()
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<DownloadStatus>(BaseDelugePayload);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.State.ShouldBe(DelugeState.Unknown);
|
||||
result.Hash.ShouldBe("abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullStateField_DeserializesToUnknown()
|
||||
{
|
||||
var json = BaseDelugePayload.Replace("\"hash\": \"abc123\",", "\"hash\": \"abc123\", \"state\": null,");
|
||||
|
||||
var result = JsonConvert.DeserializeObject<DownloadStatus>(json);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.State.ShouldBe(DelugeState.Unknown);
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
|
||||
Loaded 100 of 424 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user