Compare commits

...
22 Commits
Author SHA1 Message Date
Flaminel 33e948d1e7 Update frontend packages (#528) 2026-03-29 17:36:02 +03:00
Flaminel 9f551f151e Fix duplicated events on the dashboard (#527) 2026-03-29 17:22:58 +03:00
Flaminel 9447cb37c0 Fix round robin toggle not changing state correctly (#526) 2026-03-29 17:22:27 +03:00
Flaminel 8183b324a0 Fix Seeker being scheduled when disabled (#523) 2026-03-28 00:25:49 +02:00
Flaminel a6a25de19c Fix Seeker not grouping season packs when checking active downloads (#522) 2026-03-28 00:18:11 +02:00
Flaminel 51a2a1b391 Add missing and upgrade search (#507) 2026-03-27 19:40:09 +02:00
Flaminel d7ab81ddcf Fix Firefox missing the bookmark icon (#520) 2026-03-26 02:14:39 +02:00
Flaminel 8da07d4e93 Fix dashboard logs initial order (#519) 2026-03-26 01:16:04 +02:00
Flaminel 20b93f6853 Fix Transmission ratio not being fetched (#518) 2026-03-24 15:13:39 +02:00
Flaminel 2ce204c1bc Fix animated counter having negative values (#515) 2026-03-23 10:18:11 +02:00
Flaminel d653b7fa3f Remove the suggested apps section (#514) 2026-03-22 23:25:59 +02:00
Flaminel 0dcb42efa2 Add failed import edge case handling for Sonarr and Radarr (#510) 2026-03-22 12:01:08 +02:00
Flaminel 820d254553 Fix PR build bot reacting to all comments (#511) 2026-03-22 12:00:03 +02:00
Flaminel 715ef5711b Add better tracking for dry run events (#512) 2026-03-19 23:53:42 +02:00
Flaminel ea3244367e Fix Windows PR build (#509) 2026-03-17 11:37:11 +02:00
Flaminel f26768bcf7 Add Windows build trigger on PR comment (#508) 2026-03-17 11:29:50 +02:00
Flaminel 87bb92fac0 Fix process time difference between valid and invalid user login (#503) 2026-03-13 14:33:36 +02:00
Flaminel 01dc90bfa7 Fix test workflows cancelling each other (#502) 2026-03-12 22:30:59 +02:00
Flaminel c37e6384a5 Add failed screen before login when the API can not be reached (#501) 2026-03-12 22:16:15 +02:00
Flaminel 70fc955d37 Add OIDC support (#500) 2026-03-12 22:12:20 +02:00
Flaminel a44f226e8a Pin dotnet build version (#499) 2026-03-10 20:42:58 +02:00
Flaminel edafde5810 Fix SQLite schema cause by process_no_content_id column (#498) 2026-03-10 20:28:23 +02:00
279 changed files with 30303 additions and 2137 deletions

No files matched your search

+2 -2
View File
@@ -74,9 +74,9 @@ jobs:
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup dotnet
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
+7 -1
View File
@@ -2,6 +2,12 @@ name: Build Frontend
on:
workflow_call:
inputs:
ref:
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
type: string
required: false
default: ''
jobs:
build-frontend:
@@ -22,7 +28,7 @@ jobs:
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ github.ref_name }}
ref: ${{ inputs.ref || github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js
+2 -2
View File
@@ -84,9 +84,9 @@ jobs:
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
@@ -8,6 +8,11 @@ on:
type: string
required: false
default: ''
ref:
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
type: string
required: false
default: ''
jobs:
build-windows-installer:
@@ -58,7 +63,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
ref: ${{ inputs.ref || github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Download frontend artifact
@@ -68,9 +73,9 @@ jobs:
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
+94
View File
@@ -0,0 +1,94 @@
name: E2E Tests
on:
push:
branches:
- main
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
pull_request:
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
workflow_call:
concurrency:
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Start services
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml up -d --build
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install E2E dependencies
working-directory: e2e
run: npm ci
- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium
- name: Wait for Keycloak
run: |
echo "Waiting for Keycloak realm to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
echo "Keycloak ready!"
- name: Wait for app
run: |
echo "Waiting for Cleanuparr to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
echo "App ready!"
- name: Run E2E tests
working-directory: e2e
run: npx playwright test
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7
- name: Stop services
if: always()
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml down
+181
View File
@@ -0,0 +1,181 @@
name: PR Build (Comment Triggered)
on:
issue_comment:
types: [created]
concurrency:
group: pr-build-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
issues: write
pull-requests: write
actions: read
jobs:
validate:
runs-on: ubuntu-latest
if: github.event.issue.pull_request != null
outputs:
build_windows: ${{ steps.parse.outputs.build_windows }}
pr_ref: ${{ steps.parse.outputs.pr_ref }}
pr_sha: ${{ steps.parse.outputs.pr_sha }}
pr_number: ${{ steps.parse.outputs.pr_number }}
steps:
- name: Parse command and check permissions
id: parse
uses: actions/github-script@v7
with:
script: |
const comment = context.payload.comment.body.trim();
// Parse supported commands
const commands = {
'/build-windows': 'build_windows'
};
const command = commands[comment];
if (!command) {
console.log(`Comment "${comment}" is not a recognized build command, skipping.`);
core.setOutput('build_windows', 'false');
return;
}
try {
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
} catch (e) {
console.log(`Could not add reaction: ${e}`);
}
// Fetch PR details
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
// Verify PR is open
if (pr.data.state !== 'open') {
console.log('PR is not open, skipping.');
core.setOutput('build_windows', 'false');
return;
}
// Block fork PRs — fork code should not run with access to secrets
const isFork = pr.data.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo;
if (isFork) {
console.log(`PR is from fork ${pr.data.head.repo.full_name}, blocking build.`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'On-demand builds are not available for PRs from forks.'
});
core.setOutput('build_windows', 'false');
return;
}
// Verify commenter has write access
let permission = 'none';
try {
const resp = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
permission = resp.data.permission;
} catch (_) {}
if (!['admin', 'write'].includes(permission)) {
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — insufficient.`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `@${context.payload.comment.user.login} Only collaborators with write access can trigger builds.`
});
core.setOutput('build_windows', 'false');
return;
}
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`);
core.setOutput(command, 'true');
// Export PR details for downstream jobs
core.setOutput('pr_ref', pr.data.head.ref);
core.setOutput('pr_sha', pr.data.head.sha);
core.setOutput('pr_number', String(pr.data.number));
build-frontend:
needs: validate
if: needs.validate.outputs.build_windows == 'true'
uses: ./.github/workflows/build-frontend.yml
with:
ref: ${{ needs.validate.outputs.pr_ref }}
secrets: inherit
build-windows:
needs: [validate, build-frontend]
if: needs.validate.outputs.build_windows == 'true'
uses: ./.github/workflows/build-windows-installer.yml
with:
ref: ${{ needs.validate.outputs.pr_ref }}
secrets: inherit
post-result:
needs: [validate, build-windows]
if: always() && needs.validate.outputs.build_windows == 'true'
runs-on: ubuntu-latest
steps:
- name: Post result comment
uses: actions/github-script@v7
with:
script: |
const buildResult = '${{ needs.build-windows.result }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const prRef = '${{ needs.validate.outputs.pr_ref }}';
const prSha = '${{ needs.validate.outputs.pr_sha }}';
const shortSha = prSha.substring(0, 7);
// Skip comment for skipped builds
if (buildResult === 'skipped') {
console.log('Build was skipped, no comment needed.');
return;
}
let body;
if (buildResult === 'success') {
body = [
`Windows installer build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`,
``,
`**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`,
`The artifact \`Cleanuparr-windows-installer\` is retained for 30 days.`
].join('\n');
} else if (buildResult === 'cancelled') {
body = [
`Windows installer build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`,
``,
`See the [workflow run](${runUrl}) for details.`
].join('\n');
} else {
body = [
`Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
``,
`See the [workflow run](${runUrl}) for details.`
].join('\n');
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt('${{ needs.validate.outputs.pr_number }}'),
body
});
+19 -6
View File
@@ -96,24 +96,33 @@ jobs:
uses: ./.github/workflows/test.yml
secrets: inherit
# Run E2E tests
e2e:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
# Build frontend once for all build jobs and cache it
build-frontend:
needs: [validate, test]
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-frontend.yml
secrets: inherit
# Build portable executables
build-executables:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-executable.yml
@@ -123,11 +132,12 @@ jobs:
# Build Windows installer
build-windows-installer:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-windows-installer.yml
@@ -137,11 +147,12 @@ jobs:
# Build macOS installers (Intel and ARM)
build-macos:
needs: [validate, test, build-frontend]
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-macos-installer.yml
@@ -151,11 +162,12 @@ jobs:
# Build and push Docker image(s)
build-docker:
needs: [validate, test]
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
uses: ./.github/workflows/build-docker.yml
with:
@@ -232,7 +244,7 @@ jobs:
# Summary job
summary:
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
runs-on: ubuntu-latest
if: always()
@@ -277,6 +289,7 @@ jobs:
}
print_result "Tests" "${{ needs.test.result }}"
print_result "E2E Tests" "${{ needs.e2e.result }}"
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
print_result "Portable Executables" "${{ needs.build-executables.result }}"
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
+3 -3
View File
@@ -15,7 +15,7 @@ on:
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -29,9 +29,9 @@ jobs:
timeout-minutes: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
+3
View File
@@ -23,6 +23,9 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
> - 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).
> - Notify on strike or download removal.
@@ -11,6 +11,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -24,4 +25,8 @@
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -1,14 +1,17 @@
using Cleanuparr.Persistence;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
// Integration tests share file-system state (config-dir used by SetupGuardMiddleware),
// so they must be run sequentially to avoid interference between factories.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace Cleanuparr.Api.Tests;
/// <summary>
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
/// The database file is created in a temp directory so both DI and static contexts share the same data.
/// Custom WebApplicationFactory that redirects all database contexts to an isolated temp directory
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
@@ -18,6 +21,8 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
ConfigurationPathProvider.SetConfigPath(_tempDir);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -26,26 +31,12 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
builder.ConfigureServices(services =>
{
// Remove the existing UsersContext registration
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
if (descriptor != null) services.Remove(descriptor);
// Also remove the DbContext registration itself
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
if (contextDescriptor != null) services.Remove(contextDescriptor);
var dbPath = Path.Combine(_tempDir, "users.db");
services.AddDbContext<UsersContext>(options =>
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
{
options.UseSqlite($"Data Source={dbPath}");
});
// Ensure DB is created
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
db.Database.EnsureCreated();
services.Remove(hostedService);
}
});
}
@@ -0,0 +1,423 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly OidcLinkWebApplicationFactory _factory;
// Shared across ordered tests
private static string? _accessToken;
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
if (_accessToken is not null)
{
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
}
[Fact, TestPriority(0)]
public async Task Setup_CreateAccountAndComplete()
{
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "linkadmin",
password = "LinkPassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(1)]
public async Task Login_StoreAccessToken()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "linkadmin",
password = "LinkPassword123!"
});
var bodyText = await response.Content.ReadAsStringAsync();
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
body.TryGetProperty("requiresTwoFactor", out var rtf)
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
rtf.GetBoolean().ShouldBeFalse();
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
_accessToken.ShouldNotBeNullOrEmpty();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
[Fact, TestPriority(2)]
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
{
var response = await _client.PostAsync("/api/account/oidc/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]
public async Task EnableOidcConfig_ViaDirectDbUpdate()
{
await _factory.EnableOidcAsync();
var statusResponse = await _client.GetAsync("/api/auth/status");
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(4)]
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
{
var response = await _client.PostAsync("/api/account/oidc/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var authUrl = body.GetProperty("authorizationUrl").GetString();
authUrl.ShouldNotBeNullOrEmpty();
authUrl.ShouldContain("authorize");
}
[Fact, TestPriority(5)]
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
{
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/settings/account");
location.ShouldContain("oidc_link_error=failed");
}
[Fact, TestPriority(6)]
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
{
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
}
[Fact, TestPriority(7)]
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
{
// First trigger StartOidcLink so the mock captures the initiatorUserId
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
// Now simulate the IdP callback with the mock's success state
var callbackResponse = await _client.GetAsync(
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = callbackResponse.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/settings/account");
location.ShouldContain("oidc_link=success");
location.ShouldNotContain("oidc_link_error");
// Verify the subject was saved to config
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
}
[Fact, TestPriority(8)]
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
{
var response = await _client.GetAsync(
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_link_error=failed");
}
[Fact, TestPriority(9)]
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
{
// Create a fresh unauthenticated client
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
#region Exclusive Mode
[Fact, TestPriority(10)]
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
{
await _factory.SetOidcExclusiveModeAsync(true);
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(11)]
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PutAsJsonAsync("/api/account/password", new
{
currentPassword = "LinkPassword123!",
newPassword = "NewPassword456!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(12)]
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsync("/api/account/plex/link", null);
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(13)]
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
{
var response = await _client.DeleteAsync("/api/account/plex/link");
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(14)]
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
{
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
{
enabled = true,
issuerUrl = "https://mock-oidc-provider.test",
clientId = "test-client",
clientSecret = "test-secret",
scopes = "openid profile email",
authorizedSubject = MockOidcAuthService.LinkedSubject,
providerName = "TestProvider",
redirectUrl = "",
exclusiveMode = true
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(15)]
public async Task OidcUnlink_ResetsExclusiveMode()
{
var response = await _client.DeleteAsync("/api/account/oidc/link");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
// Verify exclusive mode was reset
var exclusiveMode = await _factory.GetExclusiveModeAsync();
exclusiveMode.ShouldBeFalse();
}
[Fact, TestPriority(16)]
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
{
// Re-enable OIDC with a linked subject but without exclusive mode
await _factory.EnableOidcAsync();
await _factory.SetOidcExclusiveModeAsync(false);
var response = await _client.PutAsJsonAsync("/api/account/password", new
{
currentPassword = "LinkPassword123!",
newPassword = "NewPassword789!"
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
#endregion
#region Test Infrastructure
public class OidcLinkWebApplicationFactory : CustomWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
});
}
public async Task EnableOidcAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return;
}
user.Oidc = new OidcConfig
{
Enabled = true,
IssuerUrl = "https://mock-oidc-provider.test",
ClientId = "test-client",
ClientSecret = "test-secret",
Scopes = "openid profile email",
AuthorizedSubject = "initial-subject",
ProviderName = "TestProvider"
};
await usersContext.SaveChangesAsync();
}
public async Task<string?> GetAuthorizedSubjectAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
return user?.Oidc.AuthorizedSubject;
}
public async Task SetOidcExclusiveModeAsync(bool enabled)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.ExclusiveMode = enabled;
await usersContext.SaveChangesAsync();
}
}
public async Task<bool> GetExclusiveModeAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
return user?.Oidc.ExclusiveMode ?? false;
}
}
private sealed class MockOidcAuthService : IOidcAuthService
{
public const string LinkSuccessState = "mock-link-success-state";
public const string NoInitiatorState = "mock-no-initiator-state";
public const string LinkedSubject = "newly-linked-subject-123";
private string? _lastInitiatorUserId;
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
{
_lastInitiatorUserId = initiatorUserId;
return Task.FromResult(new OidcAuthorizationResult
{
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
State = LinkSuccessState
});
}
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
{
if (state == LinkSuccessState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = LinkedSubject,
PreferredUsername = "linkuser",
Email = "link@example.com",
InitiatorUserId = _lastInitiatorUserId
});
}
if (state == NoInitiatorState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = LinkedSubject,
InitiatorUserId = null // No initiator — controller should redirect with error
});
}
return Task.FromResult(new OidcCallbackResult
{
Success = false,
Error = "Invalid or expired OIDC state"
});
}
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
{
var code = Guid.NewGuid().ToString("N");
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = expiresIn
});
return code;
}
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
}
#endregion
}
@@ -10,6 +10,7 @@ namespace Cleanuparr.Api.Tests.Features.Auth;
/// Uses a single shared factory to avoid static state conflicts.
/// Tests are ordered to build on each other: setup → login → protected endpoints.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
{
@@ -243,6 +244,30 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(16)]
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "nonexistent-one-time-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(17)]
public async Task AuthStatus_IncludesOidcFields()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// Verify OIDC fields exist in the response (values depend on shared static DB state)
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
}
#region TOTP helpers
private static string _totpSecret = "";
@@ -0,0 +1,182 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Tests that the login endpoint always runs BCrypt verification regardless of
/// username validity, preventing timing-based username enumeration.
/// </summary>
[Collection("Login Timing Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly TimingTestWebApplicationFactory _factory;
public LoginTimingTests(TimingTestWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact, TestPriority(0)]
public async Task Login_NoUserExists_StillCallsPasswordVerification()
{
_factory.TrackingPasswordService.Reset();
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "nouser",
password = "SomePassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
}
[Fact, TestPriority(1)]
public async Task Setup_CreateAccountAndComplete()
{
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "timingtest",
password = "TimingTestPassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(2)]
public async Task Login_ValidUsername_CallsPasswordVerification()
{
_factory.TrackingPasswordService.Reset();
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "timingtest",
password = "TimingTestPassword123!"
});
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
}
[Fact, TestPriority(3)]
public async Task Login_NonexistentUsername_StillCallsPasswordVerification()
{
_factory.TrackingPasswordService.Reset();
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "doesnotexist",
password = "SomePassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
}
[Fact, TestPriority(4)]
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
{
// Set lockout state directly in the database to avoid timing sensitivity
using (var scope = _factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await context.Users.FirstAsync();
user.FailedLoginAttempts = 5;
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
await context.SaveChangesAsync();
}
_factory.TrackingPasswordService.Reset();
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "timingtest",
password = "WrongPassword!"
});
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
// Reset lockout for subsequent tests
using (var scope = _factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await context.Users.FirstAsync();
user.FailedLoginAttempts = 0;
user.LockoutEnd = null;
await context.SaveChangesAsync();
}
}
[Fact, TestPriority(5)]
public async Task Login_TimingConsistency_InvalidAndValidUsernamesTakeSimilarTime()
{
const int iterations = 10;
// Warm up the server and BCrypt static init
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "warmup",
password = "WarmupPassword123!"
});
var invalidTimings = new List<long>(iterations);
var validTimings = new List<long>(iterations);
for (var i = 0; i < iterations; i++)
{
// Alternate to avoid ordering bias
var invalidSw = Stopwatch.StartNew();
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = $"nonexistent_{i}",
password = "SomePassword123!"
});
invalidSw.Stop();
invalidTimings.Add(invalidSw.ElapsedMilliseconds);
var validSw = Stopwatch.StartNew();
await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "timingtest",
password = "WrongPasswordForTiming!"
});
validSw.Stop();
validTimings.Add(validSw.ElapsedMilliseconds);
}
var invalidMedian = Median(invalidTimings);
var validMedian = Median(validTimings);
// The invalid-username path must not be suspiciously fast
invalidMedian.ShouldBeGreaterThan(50,
$"Non-existent username median too fast ({invalidMedian}ms) — BCrypt may have been skipped");
// Medians should be in the same ballpark
var ratio = invalidMedian > validMedian
? (double)invalidMedian / validMedian
: (double)validMedian / invalidMedian;
ratio.ShouldBeLessThan(3.0,
$"Timing difference too large: invalid median={invalidMedian}ms, valid median={validMedian}ms (ratio={ratio:F1}x)");
}
private static long Median(List<long> values)
{
values.Sort();
var mid = values.Count / 2;
return values.Count % 2 == 0
? (values[mid - 1] + values[mid]) / 2
: values[mid];
}
}
@@ -0,0 +1,628 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for the OIDC authentication flow.
/// Uses a mock IOidcAuthService to simulate IdP behavior.
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly OidcWebApplicationFactory _factory;
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // We want to inspect redirects
});
}
[Fact, TestPriority(0)]
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
// but the controller returns BadRequest because OIDC is not configured
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
[Fact, TestPriority(1)]
public async Task Setup_CreateAccountAndComplete()
{
// Create account
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "admin",
password = "TestPassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
// Complete setup (skip 2FA for this test suite)
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact, TestPriority(2)]
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "some-random-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(4)]
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
{
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/auth/login");
location.ShouldContain("oidc_error=provider_error");
}
[Fact, TestPriority(5)]
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
{
var response = await _client.GetAsync("/api/auth/oidc/callback");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=invalid_request");
}
[Fact, TestPriority(6)]
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
{
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=invalid_request");
}
[Fact, TestPriority(7)]
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
{
// Even with code and state, if the state is invalid the mock will return failure
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=authentication_failed");
}
[Fact, TestPriority(8)]
public async Task EnableOidcConfig_ViaDirectDbUpdate()
{
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
await _factory.EnableOidcAsync();
// Verify auth status reflects OIDC enabled
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
}
[Fact, TestPriority(9)]
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var authUrl = body.GetProperty("authorizationUrl").GetString();
authUrl.ShouldNotBeNullOrEmpty();
authUrl.ShouldContain("authorize");
}
[Fact, TestPriority(10)]
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
{
// Use the mock's valid state to simulate a successful callback
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("/auth/oidc/callback");
location.ShouldContain("code=");
// Should NOT contain oidc_error
location.ShouldNotContain("oidc_error");
}
[Fact, TestPriority(11)]
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
{
// First, trigger a valid callback to get a one-time code
var callbackResponse = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = callbackResponse.Headers.Location?.ToString();
location.ShouldNotBeNull();
// Extract the one-time code from the redirect URL
var uri = new Uri("http://localhost" + location);
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
var oneTimeCode = queryParams["code"];
oneTimeCode.ShouldNotBeNullOrEmpty();
// Exchange the one-time code for tokens
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = oneTimeCode
});
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
}
[Fact, TestPriority(12)]
public async Task OidcExchange_SameCodeTwice_SecondFails()
{
// First, trigger a valid callback
var callbackResponse = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
var location = callbackResponse.Headers.Location?.ToString()!;
var uri = new Uri("http://localhost" + location);
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
var oneTimeCode = queryParams["code"]!;
// First exchange succeeds
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
// Second exchange with same code fails
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(13)]
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "completely-invalid-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact, TestPriority(14)]
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
{
// Use the mock's state that returns a different subject
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("oidc_error=unauthorized");
}
[Fact, TestPriority(15)]
public async Task AuthStatus_IncludesOidcFields()
{
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
}
[Fact, TestPriority(16)]
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
// Should succeed (no 2FA since we skipped it in setup)
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// No 2FA, so should have tokens directly
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
}
[Fact, TestPriority(17)]
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
{
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
await _factory.SetOidcAuthorizedSubjectAsync("");
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
// Restore for subsequent tests
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
}
[Fact, TestPriority(17)]
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
{
// OIDC should be disabled when essential config (IssuerUrl) is missing
await _factory.SetOidcIssuerUrlAsync("");
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
// Restore for subsequent tests
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
}
[Fact, TestPriority(17)]
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
{
// Clear the authorized subject — any OIDC user should be allowed
await _factory.SetOidcAuthorizedSubjectAsync("");
// Use the "wrong subject" state — this returns a different subject than the authorized one
// With no linked subject, it should still succeed
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("code=");
location.ShouldNotContain("oidc_error");
// Restore for subsequent tests
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
}
[Fact, TestPriority(18)]
public async Task OidcExchange_RandomCode_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
{
code = "completely-random-nonexistent-code"
});
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
#region Exclusive Mode
[Fact, TestPriority(19)]
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
{
await _factory.SetOidcExclusiveModeAsync(true);
var response = await _client.GetAsync("/api/auth/status");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
}
[Fact, TestPriority(20)]
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(21)]
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
{
loginToken = "some-token",
code = "123456"
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(22)]
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(23)]
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
{
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
{
pinId = 12345
});
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact, TestPriority(24)]
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
{
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(25)]
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
{
var response = await _client.GetAsync(
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
var location = response.Headers.Location?.ToString();
location.ShouldNotBeNull();
location.ShouldContain("code=");
location.ShouldNotContain("oidc_error");
}
[Fact, TestPriority(26)]
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
{
await _factory.SetOidcExclusiveModeAsync(false);
var response = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "admin",
password = "TestPassword123!"
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
#endregion
#region Test Infrastructure
/// <summary>
/// Custom factory that replaces IOidcAuthService with a mock for testing.
/// </summary>
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _tempDir;
public OidcWebApplicationFactory()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
// Redirect all database contexts to this factory's temp directory.
ConfigurationPathProvider.SetConfigPath(_tempDir);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Replace IOidcAuthService with mock
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
// with a disposed ILoggerFactory from the previous factory lifecycle.
// Auth tests don't depend on background job scheduling, so this is safe.
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
services.Remove(hostedService);
});
}
/// <summary>
/// Enables OIDC on the user in the UsersContext database.
/// </summary>
public async Task EnableOidcAsync()
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return;
}
user.Oidc = new OidcConfig
{
Enabled = true,
IssuerUrl = "https://mock-oidc-provider.test",
ClientId = "test-client",
ClientSecret = "test-secret",
Scopes = "openid profile email",
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
ProviderName = "TestProvider"
};
await usersContext.SaveChangesAsync();
}
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.IssuerUrl = issuerUrl;
await usersContext.SaveChangesAsync();
}
}
public async Task SetOidcAuthorizedSubjectAsync(string subject)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.AuthorizedSubject = subject;
await usersContext.SaveChangesAsync();
}
}
public async Task SetOidcExclusiveModeAsync(bool enabled)
{
using var scope = Services.CreateScope();
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
var user = await usersContext.Users.FirstOrDefaultAsync();
if (user is not null)
{
user.Oidc.ExclusiveMode = enabled;
await usersContext.SaveChangesAsync();
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_tempDir))
{
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
}
}
}
/// <summary>
/// Mock OIDC auth service that simulates IdP behavior without network calls.
/// </summary>
private sealed class MockOidcAuthService : IOidcAuthService
{
public const string ValidState = "mock-valid-state";
public const string WrongSubjectState = "mock-wrong-subject-state";
public const string AuthorizedSubject = "mock-authorized-subject-123";
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
{
return Task.FromResult(new OidcAuthorizationResult
{
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
State = ValidState
});
}
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
{
if (state == ValidState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = AuthorizedSubject,
PreferredUsername = "testuser",
Email = "testuser@example.com"
});
}
if (state == WrongSubjectState)
{
return Task.FromResult(new OidcCallbackResult
{
Success = true,
Subject = "wrong-subject-that-doesnt-match",
PreferredUsername = "wronguser",
Email = "wrong@example.com"
});
}
return Task.FromResult(new OidcCallbackResult
{
Success = false,
Error = "Invalid or expired OIDC state"
});
}
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
{
var code = Guid.NewGuid().ToString("N");
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = expiresIn
});
return code;
}
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
{
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
}
}
#endregion
}
@@ -0,0 +1,29 @@
using Cleanuparr.Infrastructure.Features.Auth;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Factory variant that replaces <see cref="IPasswordService"/> with a
/// <see cref="TrackingPasswordService"/> spy so tests can assert that
/// password verification is always called regardless of username validity.
/// </summary>
public class TimingTestWebApplicationFactory : CustomWebApplicationFactory
{
public TrackingPasswordService TrackingPasswordService { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
// Replace IPasswordService with our tracking spy
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IPasswordService));
if (descriptor != null) services.Remove(descriptor);
services.AddSingleton<IPasswordService>(TrackingPasswordService);
});
}
}
@@ -0,0 +1,33 @@
using Cleanuparr.Infrastructure.Features.Auth;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Spy wrapper around <see cref="PasswordService"/> that tracks calls to
/// <see cref="VerifyPassword"/> for behavioral assertions in timing tests.
/// </summary>
public sealed class TrackingPasswordService : IPasswordService
{
private readonly PasswordService _inner = new();
private int _verifyPasswordCallCount;
public int VerifyPasswordCallCount => _verifyPasswordCallCount;
public string DummyHash => _inner.DummyHash;
public string HashPassword(string password)
{
return _inner.HashPassword(password);
}
public bool VerifyPassword(string password, string hash)
{
Interlocked.Increment(ref _verifyPasswordCallCount);
return _inner.VerifyPassword(password, hash);
}
public void Reset()
{
Interlocked.Exchange(ref _verifyPasswordCallCount, 0);
}
}
@@ -0,0 +1,359 @@
using System.Text.Json;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class CustomFormatScoreControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly CustomFormatScoreController _controller;
public CustomFormatScoreControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_controller = new CustomFormatScoreController(_dataContext);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static JsonElement GetResponseBody(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
return JsonDocument.Parse(json).RootElement;
}
#region GetCustomFormatScores Tests
[Fact]
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
var body = GetResponseBody(result);
body.GetProperty("Page").GetInt32().ShouldBe(1);
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
var body = GetResponseBody(result);
body.GetProperty("PageSize").GetInt32().ShouldBe(100);
}
[Fact]
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(hideMet: true);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
}
[Fact]
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(search: "matrix");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
lastSynced: DateTime.UtcNow.AddHours(-2));
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
lastSynced: DateTime.UtcNow.AddHours(-1));
var result = await _controller.GetCustomFormatScores(sortBy: "date");
var body = GetResponseBody(result);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
}
[Fact]
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
itemType: InstanceType.Sonarr);
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
}
[Fact]
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
for (int i = 1; i <= 7; i++)
{
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
}
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
}
#endregion
#region GetRecentUpgrades Tests
[Fact]
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
{
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
}
[Fact]
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
}
[Fact]
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
var upgrade = body.GetProperty("Items")[0];
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
}
[Fact]
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
}
[Fact]
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// 100 -> 200 -> 300 = two upgrades for the same item
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// Old upgrade (outside 7-day window)
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
// Recent upgrade (inside 7-day window)
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades(days: 7);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
}
[Fact]
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// Item 1: upgrade happened 5 days ago
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
// Item 2: upgrade happened 1 day ago
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
var result = await _controller.GetRecentUpgrades();
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items.GetArrayLength().ShouldBe(2);
// Most recent upgrade (item 2) should be first
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
}
#endregion
#region GetStats Tests
[Fact]
public async Task GetStats_WithNoEntries_ReturnsZeroes()
{
var result = await _controller.GetStats();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
stats.TotalTracked.ShouldBe(0);
stats.BelowCutoff.ShouldBe(0);
stats.AtOrAboveCutoff.ShouldBe(0);
stats.RecentUpgrades.ShouldBe(0);
}
[Fact]
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetStats();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
stats.TotalTracked.ShouldBe(3);
stats.BelowCutoff.ShouldBe(1);
stats.AtOrAboveCutoff.ShouldBe(2);
}
[Fact]
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
// Upgrade within 7 days
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
// Upgrade outside 7 days (should not be counted)
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
var result = await _controller.GetStats();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
stats.RecentUpgrades.ShouldBe(1);
}
#endregion
#region Helpers
private void AddScoreEntry(
Guid arrInstanceId,
long externalItemId,
string title,
int currentScore,
int cutoffScore,
InstanceType itemType = InstanceType.Radarr,
DateTime? lastSynced = null)
{
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = arrInstanceId,
ExternalItemId = externalItemId,
EpisodeId = 0,
ItemType = itemType,
Title = title,
FileId = externalItemId * 10,
CurrentScore = currentScore,
CutoffScore = cutoffScore,
QualityProfileName = "HD",
LastSyncedAt = lastSynced ?? DateTime.UtcNow
});
_dataContext.SaveChanges();
}
private void AddHistoryEntry(
Guid arrInstanceId,
long externalItemId,
int score,
DateTime recordedAt,
long episodeId = 0,
int cutoffScore = 500,
InstanceType itemType = InstanceType.Radarr)
{
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = arrInstanceId,
ExternalItemId = externalItemId,
EpisodeId = episodeId,
ItemType = itemType,
Title = $"Item {externalItemId}",
Score = score,
CutoffScore = cutoffScore,
RecordedAt = recordedAt
});
_dataContext.SaveChanges();
}
#endregion
}
@@ -0,0 +1,221 @@
using System.Text.Json;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class SearchStatsControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly SearchStatsController _controller;
public SearchStatsControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
_controller = new SearchStatsController(_dataContext, _eventsContext);
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
private static JsonElement GetResponseBody(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
var json = JsonSerializer.Serialize(okResult.Value);
return JsonDocument.Parse(json).RootElement;
}
#region ParseEventData (tested via GetEvents)
[Fact]
public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults()
{
AddSearchEvent(data: null);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
item.GetProperty("Items").GetArrayLength().ShouldBe(0);
}
[Fact]
public async Task GetEvents_WithValidFullJson_ParsesAllFields()
{
var data = JsonSerializer.Serialize(new
{
InstanceName = "My Radarr",
ItemCount = 3,
Items = new[] { "Movie A", "Movie B", "Movie C" },
SearchType = "Proactive",
GrabbedItems = new[] { new { Title = "Movie A", Quality = "Bluray-1080p" } }
});
AddSearchEvent(data: data);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("InstanceName").GetString().ShouldBe("My Radarr");
item.GetProperty("ItemCount").GetInt32().ShouldBe(3);
item.GetProperty("Items").GetArrayLength().ShouldBe(3);
item.GetProperty("Items")[0].GetString().ShouldBe("Movie A");
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
}
[Fact]
public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields()
{
// Only InstanceName is present, other fields missing
var data = JsonSerializer.Serialize(new { InstanceName = "Partial Instance" });
AddSearchEvent(data: data);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("InstanceName").GetString().ShouldBe("Partial Instance");
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
item.GetProperty("Items").GetArrayLength().ShouldBe(0);
}
[Fact]
public async Task GetEvents_WithMalformedJson_ReturnsUnknownDefaults()
{
AddSearchEvent(data: "not valid json {{{");
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
}
[Fact]
public async Task GetEvents_WithSearchTypeReplacement_ParsesCorrectEnum()
{
var data = JsonSerializer.Serialize(new
{
InstanceName = "Sonarr",
SearchType = "Replacement"
});
AddSearchEvent(data: data);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
}
#endregion
#region GetEvents Filtering
[Fact]
public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
// Event matching radarr's URL
AddSearchEvent(instanceUrl: radarr.Url.ToString(), instanceType: InstanceType.Radarr,
data: JsonSerializer.Serialize(new { InstanceName = "Radarr Event" }));
// Event matching sonarr's URL
AddSearchEvent(instanceUrl: sonarr.Url.ToString(), instanceType: InstanceType.Sonarr,
data: JsonSerializer.Serialize(new { InstanceName = "Sonarr Event" }));
var result = await _controller.GetEvents(instanceId: radarr.Id);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Radarr Event");
}
[Fact]
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
{
var cycleA = Guid.NewGuid();
var cycleB = Guid.NewGuid();
AddSearchEvent(cycleId: cycleA, data: JsonSerializer.Serialize(new { InstanceName = "Cycle A" }));
AddSearchEvent(cycleId: cycleB, data: JsonSerializer.Serialize(new { InstanceName = "Cycle B" }));
var result = await _controller.GetEvents(cycleId: cycleA);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Cycle A");
}
[Fact]
public async Task GetEvents_WithSearchFilter_FiltersOnDataField()
{
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } }));
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } }));
var result = await _controller.GetEvents(search: "matrix");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
}
[Fact]
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
{
for (int i = 0; i < 5; i++)
{
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" }));
}
var result = await _controller.GetEvents(page: 2, pageSize: 2);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(5/2) = 3
body.GetProperty("Page").GetInt32().ShouldBe(2);
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
}
#endregion
#region Helpers
private void AddSearchEvent(
string? data = null,
string? instanceUrl = null,
InstanceType? instanceType = null,
Guid? cycleId = null,
SearchCommandStatus? searchStatus = null)
{
_eventsContext.Events.Add(new AppEvent
{
EventType = EventType.SearchTriggered,
Message = "Search triggered",
Severity = EventSeverity.Information,
Data = data,
InstanceUrl = instanceUrl,
InstanceType = instanceType,
CycleId = cycleId,
SearchStatus = searchStatus,
Timestamp = DateTime.UtcNow
});
_eventsContext.SaveChanges();
}
#endregion
}
@@ -0,0 +1,324 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
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.Seeker;
public class SeekerConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly ILogger<SeekerConfigController> _logger;
private readonly IJobManagementService _jobManagementService;
private readonly SeekerConfigController _controller;
public SeekerConfigControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_logger = Substitute.For<ILogger<SeekerConfigController>>();
_jobManagementService = Substitute.For<IJobManagementService>();
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
#region GetSeekerConfig Tests
[Fact]
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var result = await _controller.GetSeekerConfig();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
var instance = response.Instances.ShouldHaveSingleItem();
instance.ArrInstanceId.ShouldBe(radarr.Id);
instance.Enabled.ShouldBeFalse();
instance.SkipTags.ShouldBeEmpty();
instance.ActiveDownloadLimit.ShouldBe(3);
instance.MinCycleTimeDays.ShouldBe(7);
}
[Fact]
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
var result = await _controller.GetSeekerConfig();
var okResult = result.ShouldBeOfType<OkObjectResult>();
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
response.Instances.Count.ShouldBe(2);
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
}
#endregion
#region UpdateSeekerConfig Tests
[Fact]
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 5,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = radarr.Id,
Enabled = false // No instances enabled
}
]
};
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
}
[Fact]
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// Default interval is 3, change to 5
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 5,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StartJob(JobType.Seeker, null, Arg.Any<string>());
}
[Fact]
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// Keep interval at default (3)
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.DidNotReceive()
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
}
[Fact]
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// UseCustomFormatScore was false (default), now enable it
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
UseCustomFormatScore = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
await _jobManagementService.Received(1)
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// First enable CF score
var config = await _dataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _dataContext.SaveChangesAsync();
// Now disable it
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
UseCustomFormatScore = false,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StopJob(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
});
await _dataContext.SaveChangesAsync();
// Set up state: CF score already enabled, search currently disabled
var config = await _dataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
config.SearchEnabled = false;
await _dataContext.SaveChangesAsync();
// Re-enable search
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = false,
UseCustomFormatScore = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
// Radarr already has a config
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = false,
SkipTags = ["old-tag"],
ActiveDownloadLimit = 2,
MinCycleTimeDays = 5
});
await _dataContext.SaveChangesAsync();
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
Instances =
[
// Update existing radarr config
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = radarr.Id,
Enabled = true,
SkipTags = ["new-tag"],
ActiveDownloadLimit = 5,
MinCycleTimeDays = 14
},
// Create new sonarr config
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = sonarr.Id,
Enabled = true,
SkipTags = ["sonarr-tag"],
ActiveDownloadLimit = 3,
MinCycleTimeDays = 7
}
]
};
await _controller.UpdateSeekerConfig(request);
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
configs.Count.ShouldBe(2);
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
radarrConfig.Enabled.ShouldBeTrue();
radarrConfig.SkipTags.ShouldContain("new-tag");
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
radarrConfig.MinCycleTimeDays.ShouldBe(14);
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
sonarrConfig.Enabled.ShouldBeTrue();
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
}
#endregion
}
@@ -0,0 +1,166 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
/// </summary>
public static class SeekerTestDataFactory
{
public static 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();
SeedDefaultData(context);
return context;
}
public static EventsContext CreateEventsContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.Options;
var context = new EventsContext(options);
context.Database.EnsureCreated();
return context;
}
private static void SeedDefaultData(DataContext context)
{
context.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = false,
IgnoredDownloads = [],
Log = new LoggingConfig()
});
context.ArrConfigs.AddRange(
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
);
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
FailedImport = new FailedImportConfig()
});
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = false },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false }
});
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
IgnoredDownloads = [],
Categories = [],
UnlinkedEnabled = false,
UnlinkedTargetCategory = "",
UnlinkedCategories = []
});
context.SeekerConfigs.Add(new SeekerConfig
{
Id = Guid.NewGuid(),
SearchEnabled = true,
ProactiveSearchEnabled = false
});
context.SaveChanges();
}
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Sonarr",
Url = new Uri("http://sonarr:8989"),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Radarr",
Url = new Uri("http://radarr:7878"),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
{
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
var instance = new ArrInstance
{
Id = Guid.NewGuid(),
Name = "Test Lidarr",
Url = new Uri("http://lidarr:8686"),
ApiKey = "test-api-key",
Enabled = enabled,
ArrConfigId = arrConfig.Id,
ArrConfig = arrConfig
};
arrConfig.Instances.Add(instance);
context.ArrInstances.Add(instance);
context.SaveChanges();
return instance;
}
}
@@ -1,6 +1,9 @@
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Auth;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
@@ -314,4 +317,65 @@ public class SensitiveDataInputTests
}
#endregion
#region UpdateOidcConfigRequest UPDATE
[Fact]
public void UpdateOidcConfigRequest_ApplyTo_WithPlaceholderClientSecret_PreservesExistingValue()
{
var request = new UpdateOidcConfigRequest
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = Placeholder,
Scopes = "openid profile email",
ProviderName = "Keycloak",
};
var existingConfig = new OidcConfig
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = "original-secret",
Scopes = "openid profile email",
ProviderName = "OIDC",
};
request.ApplyTo(existingConfig);
existingConfig.ClientSecret.ShouldBe("original-secret");
existingConfig.ProviderName.ShouldBe("Keycloak");
}
[Fact]
public void UpdateOidcConfigRequest_ApplyTo_WithRealClientSecret_UpdatesValue()
{
var request = new UpdateOidcConfigRequest
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = "brand-new-secret",
Scopes = "openid profile email",
ProviderName = "Keycloak",
};
var existingConfig = new OidcConfig
{
Enabled = true,
IssuerUrl = "http://localhost:8080/realms/test",
ClientId = "cleanuparr",
ClientSecret = "original-secret",
Scopes = "openid profile email",
ProviderName = "OIDC",
};
request.ApplyTo(existingConfig);
existingConfig.ClientSecret.ShouldBe("brand-new-secret");
}
#endregion
}
@@ -0,0 +1,9 @@
namespace Cleanuparr.Api.Tests;
/// <summary>
/// Auth integration tests share the file-system config directory (users.db via
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
/// forces sequential execution and prevents inter-factory interference.
/// </summary>
[CollectionDefinition("Auth Integration Tests")]
public class AuthIntegrationTestsCollection { }
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}
@@ -57,8 +57,13 @@ public class JobsController : ControllerBase
}
[HttpPost("{jobType}/start")]
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually controlled");
}
try
{
// Get the schedule from the request body if provided
@@ -82,6 +87,11 @@ public class JobsController : ControllerBase
[HttpPost("{jobType}/trigger")]
public async Task<IActionResult> TriggerJob(JobType jobType)
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually triggered");
}
try
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
@@ -102,6 +112,11 @@ public class JobsController : ControllerBase
[HttpPut("{jobType}/schedule")]
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job schedule cannot be manually modified");
}
if (scheduleRequest?.Schedule == null)
{
return BadRequest("Schedule is required");
@@ -77,6 +77,7 @@ public class StrikesController : ControllerBase
IsMarkedForRemoval = d.IsMarkedForRemoval,
IsRemoved = d.IsRemoved,
IsReturning = d.IsReturning,
HasDryRunStrikes = d.Strikes.Any(s => s.IsDryRun),
Strikes = d.Strikes
.OrderByDescending(s => s.CreatedAt)
.Select(s => new StrikeDetailDto
@@ -86,6 +87,7 @@ public class StrikesController : ControllerBase
CreatedAt = s.CreatedAt,
LastDownloadedBytes = s.LastDownloadedBytes,
JobRunId = s.JobRunId,
IsDryRun = s.IsDryRun,
}).ToList(),
}).ToList();
@@ -120,6 +122,7 @@ public class StrikesController : ControllerBase
CreatedAt = s.CreatedAt,
DownloadId = s.DownloadItem.DownloadId,
Title = s.DownloadItem.Title,
IsDryRun = s.IsDryRun,
})
.ToListAsync();
@@ -169,6 +172,7 @@ public class DownloadItemStrikesDto
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
public bool HasDryRunStrikes { get; set; }
public List<StrikeDetailDto> Strikes { get; set; } = [];
}
@@ -179,6 +183,7 @@ public class StrikeDetailDto
public DateTime CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
public bool IsDryRun { get; set; }
}
public class RecentStrikeDto
@@ -188,4 +193,5 @@ public class RecentStrikeDto
public DateTime CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsDryRun { get; set; }
}
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
@@ -30,9 +29,6 @@ public static class MainDI
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
@@ -60,14 +56,6 @@ public static class MainDI
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("notification-queue", e =>
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
@@ -94,6 +82,9 @@ public static class MainDI
// Add HTTP client for Plex authentication
services.AddHttpClient("PlexAuth");
// Add HTTP client for OIDC authentication
services.AddHttpClient("OidcAuth");
return services;
}
@@ -5,8 +5,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -33,6 +31,7 @@ public static class ServicesDI
.AddSingleton<IPasswordService, PasswordService>()
.AddSingleton<ITotpService, TotpService>()
.AddScoped<IPlexAuthService, PlexAuthService>()
.AddScoped<IOidcAuthService, OidcAuthService>()
.AddScoped<IEventPublisher, EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
@@ -48,8 +47,9 @@ public static class ServicesDI
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<Seeker>()
.AddScoped<CustomFormatScoreSyncer>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
@@ -66,5 +66,6 @@ public static class ServicesDI
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
.AddSingleton(TimeProvider.System)
.AddSingleton<AppStatusSnapshot>()
.AddHostedService<AppStatusRefreshService>();
.AddHostedService<AppStatusRefreshService>()
.AddHostedService<SeekerCommandMonitor>();
}
@@ -0,0 +1,44 @@
using Cleanuparr.Infrastructure.Extensions;
namespace Cleanuparr.Api.Extensions;
public static class HttpRequestExtensions
{
/// <summary>
/// Returns the request PathBase as a safe relative path.
/// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks.
/// </summary>
public static string GetSafeBasePath(this HttpRequest request)
{
var basePath = request.PathBase.Value?.TrimEnd('/') ?? "";
if (basePath.Contains("://") || basePath.StartsWith("//"))
{
return "";
}
return basePath;
}
/// <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.
/// </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}";
}
}
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record OidcExchangeRequest
{
[Required]
public required string Code { get; init; }
}
@@ -0,0 +1,49 @@
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence.Models.Auth;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
public sealed record UpdateOidcConfigRequest
{
public bool Enabled { get; init; }
public string IssuerUrl { get; init; } = string.Empty;
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
public string Scopes { get; init; } = "openid profile email";
public string ProviderName { get; init; } = "OIDC";
public string RedirectUrl { get; init; } = string.Empty;
public bool ExclusiveMode { get; init; }
public void ApplyTo(OidcConfig existingConfig)
{
var previousIssuerUrl = existingConfig.IssuerUrl;
existingConfig.Enabled = Enabled;
existingConfig.IssuerUrl = IssuerUrl;
existingConfig.ClientId = ClientId;
existingConfig.Scopes = Scopes;
existingConfig.ProviderName = ProviderName;
existingConfig.RedirectUrl = RedirectUrl;
existingConfig.ExclusiveMode = ExclusiveMode;
if (!ClientSecret.IsPlaceholder())
{
existingConfig.ClientSecret = ClientSecret;
}
// AuthorizedSubject is intentionally NOT mapped here — it is set only via the OIDC link callback
if (previousIssuerUrl != IssuerUrl)
{
OidcAuthService.ClearDiscoveryCache();
}
}
}
@@ -5,4 +5,7 @@ public sealed record AuthStatusResponse
public required bool SetupCompleted { get; init; }
public bool PlexLinked { get; init; }
public bool AuthBypassActive { get; init; }
public bool OidcEnabled { get; init; }
public string OidcProviderName { get; init; } = string.Empty;
public bool OidcExclusiveMode { get; init; }
}
@@ -0,0 +1,6 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record OidcStartResponse
{
public required string AuthorizationUrl { get; init; }
}
@@ -1,5 +1,6 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
@@ -9,6 +10,7 @@ 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;
@@ -22,6 +24,7 @@ public sealed class AccountController : ControllerBase
private readonly IPasswordService _passwordService;
private readonly ITotpService _totpService;
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AccountController> _logger;
public AccountController(
@@ -29,12 +32,14 @@ public sealed class AccountController : ControllerBase
IPasswordService passwordService,
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AccountController> logger)
{
_usersContext = usersContext;
_passwordService = passwordService;
_totpService = totpService;
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
}
@@ -42,7 +47,10 @@ public sealed class AccountController : ControllerBase
public async Task<IActionResult> GetAccountInfo()
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (user is null)
{
return Unauthorized();
}
return Ok(new AccountInfoResponse
{
@@ -57,247 +65,230 @@ public sealed class AccountController : ControllerBase
[HttpPut("password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
await UsersContext.Lock.WaitAsync();
try
if (await IsOidcExclusiveModeActive())
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
{
return BadRequest(new { error = "Current password is incorrect" });
}
DateTime now = DateTime.UtcNow;
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
user.UpdatedAt = now;
// Revoke all existing refresh tokens so old sessions can't be reused
var activeTokens = await _usersContext.RefreshTokens
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
.ToListAsync();
foreach (var token in activeTokens)
{
token.RevokedAt = now;
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Password changed for user {Username}", user.Username);
return Ok(new { message = "Password changed" });
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
}
finally
var user = await GetCurrentUser();
if (user is null)
{
UsersContext.Lock.Release();
return Unauthorized();
}
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
{
return BadRequest(new { error = "Current password is incorrect" });
}
DateTime now = DateTime.UtcNow;
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
user.UpdatedAt = now;
// Revoke all existing refresh tokens so old sessions can't be reused
var activeTokens = await _usersContext.RefreshTokens
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
.ToListAsync();
foreach (var token in activeTokens)
{
token.RevokedAt = now;
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Password changed for user {Username}", user.Username);
return Ok(new { message = "Password changed" });
}
[HttpPost("2fa/regenerate")]
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null)
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
return Unauthorized();
}
// Verify current credentials
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
// Verify current credentials
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
}
// Generate new TOTP
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Replace recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
foreach (var code in recoveryCodes)
{
_usersContext.RecoveryCodes.Add(new RecoveryCode
{
return BadRequest(new { error = "Incorrect password" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
}
// Generate new TOTP
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Replace recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
foreach (var code in recoveryCodes)
{
_usersContext.RecoveryCodes.Add(new RecoveryCode
{
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _totpService.HashRecoveryCode(code),
IsUsed = false
});
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _totpService.HashRecoveryCode(code),
IsUsed = false
});
}
finally
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
UsersContext.Lock.Release();
}
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
});
}
[HttpPost("2fa/enable")]
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null)
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
return Unauthorized();
}
if (user.TotpEnabled)
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
// Generate new TOTP
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Replace any existing recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
foreach (var code in recoveryCodes)
{
_usersContext.RecoveryCodes.Add(new RecoveryCode
{
return Conflict(new { error = "2FA is already enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
// Generate new TOTP
var secret = _totpService.GenerateSecret();
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
// Replace any existing recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
foreach (var code in recoveryCodes)
{
_usersContext.RecoveryCodes.Add(new RecoveryCode
{
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _totpService.HashRecoveryCode(code),
IsUsed = false
});
}
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _totpService.HashRecoveryCode(code),
IsUsed = false
});
}
finally
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
return Ok(new TotpSetupResponse
{
UsersContext.Lock.Release();
}
Secret = secret,
QrCodeUri = qrUri,
RecoveryCodes = recoveryCodes
});
}
[HttpPost("2fa/enable/verify")]
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
{
await UsersContext.Lock.WaitAsync();
try
var user = await GetCurrentUser();
if (user is null)
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
return Ok(new { message = "2FA enabled" });
return Unauthorized();
}
finally
if (user.TotpEnabled)
{
UsersContext.Lock.Release();
return Conflict(new { error = "2FA is already enabled" });
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
return Ok(new { message = "2FA enabled" });
}
[HttpPost("2fa/disable")]
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
{
await UsersContext.Lock.WaitAsync();
try
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null)
{
var user = await GetCurrentUser(includeRecoveryCodes: true);
if (user is null) return Unauthorized();
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA is not enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
}
user.TotpEnabled = false;
user.TotpSecret = string.Empty;
user.UpdatedAt = DateTime.UtcNow;
// Remove all recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
return Ok(new { message = "2FA disabled" });
return Unauthorized();
}
finally
if (!user.TotpEnabled)
{
UsersContext.Lock.Release();
return BadRequest(new { error = "2FA is not enabled" });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
}
user.TotpEnabled = false;
user.TotpSecret = string.Empty;
user.UpdatedAt = DateTime.UtcNow;
// Remove all recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
return Ok(new { message = "2FA disabled" });
}
[HttpGet("api-key")]
public async Task<IActionResult> GetApiKey()
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (user is null)
{
return Unauthorized();
}
return Ok(new { apiKey = user.ApiKey });
}
@@ -305,33 +296,33 @@ public sealed class AccountController : ControllerBase
[HttpPost("api-key/regenerate")]
public async Task<IActionResult> RegenerateApiKey()
{
await UsersContext.Lock.WaitAsync();
try
var user = await GetCurrentUser();
if (user is null)
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
return Ok(new { apiKey = user.ApiKey });
}
finally
{
UsersContext.Lock.Release();
return Unauthorized();
}
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
return Ok(new { apiKey = user.ApiKey });
}
[HttpPost("plex/link")]
public async Task<IActionResult> StartPlexLink()
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
}
var pin = await _plexAuthService.RequestPin();
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
@@ -340,6 +331,11 @@ public sealed class AccountController : ControllerBase
[HttpPost("plex/link/verify")]
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
if (!pinResult.Completed || pinResult.AuthToken is null)
@@ -349,23 +345,194 @@ public sealed class AccountController : ControllerBase
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
user.Username, plexAccount.Username);
return Ok(new { completed = true, plexUsername = plexAccount.Username });
}
[HttpDelete("plex/link")]
public async Task<IActionResult> UnlinkPlex()
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
}
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
user.PlexAccountId = null;
user.PlexUsername = null;
user.PlexEmail = null;
user.PlexAuthToken = null;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
return Ok(new { message = "Plex account unlinked" });
}
[HttpGet("oidc")]
public async Task<IActionResult> GetOidcConfig()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
return Ok(user.Oidc);
}
[HttpPut("oidc")]
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
{
try
{
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 });
}
}
[HttpPost("oidc/link")]
public async Task<IActionResult> StartOidcLink()
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
if (user.Oidc is not { Enabled: true })
{
return BadRequest(new { error = "OIDC is not enabled" });
}
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
_logger.LogDebug("OIDC link start: using redirect URI {RedirectUri}", redirectUri);
try
{
var result = await _oidcAuthService.StartAuthorization(redirectUri, user.Id.ToString());
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
return StatusCode(429, new { error = ex.Message });
}
}
/// <remarks>
/// This endpoint must be [AllowAnonymous] because the IdP redirects the user's browser here
/// without a Bearer token. Security is ensured by validating that the OIDC flow was initiated
/// by an authenticated user (InitiatorUserId stored in the flow state during StartOidcLink).
/// </remarks>
[AllowAnonymous]
[HttpGet("oidc/link/callback")]
public async Task<IActionResult> OidcLinkCallback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error)
{
var basePath = HttpContext.Request.GetSafeBasePath();
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
// Fetch any user to get the configured redirect URL for the OIDC callback
var oidcConfig = (await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync())?.Oidc;
var redirectUri = GetOidcLinkCallbackUrl(oidcConfig?.RedirectUrl);
_logger.LogDebug("OIDC link callback: using redirect URI {RedirectUri}", redirectUri);
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
if (!result.Success || string.IsNullOrEmpty(result.Subject))
{
_logger.LogWarning("OIDC link callback failed: {Error}", result.Error);
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
// Verify the flow was initiated by an authenticated user
if (string.IsNullOrEmpty(result.InitiatorUserId) ||
!Guid.TryParse(result.InitiatorUserId, out var initiatorId))
{
_logger.LogWarning("OIDC link callback missing initiator user ID");
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
// Save the authorized subject to the user's OIDC config
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.Id == initiatorId);
if (user is null)
{
_logger.LogWarning("OIDC link callback initiator user not found: {UserId}", result.InitiatorUserId);
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
}
user.Oidc.AuthorizedSubject = result.Subject;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
result.Subject, user.Username);
return Redirect($"{basePath}/settings/account?oidc_link=success");
}
[HttpDelete("oidc/link")]
public async Task<IActionResult> UnlinkOidc()
{
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
if (user is null)
{
return Unauthorized();
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.Oidc.AuthorizedSubject = string.Empty;
user.Oidc.ExclusiveMode = false;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
user.Username, plexAccount.Username);
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
return Ok(new { completed = true, plexUsername = plexAccount.Username });
return Ok(new { message = "OIDC account unlinked" });
}
finally
{
@@ -373,30 +540,24 @@ public sealed class AccountController : ControllerBase
}
}
[HttpDelete("plex/link")]
public async Task<IActionResult> UnlinkPlex()
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
{
await UsersContext.Lock.WaitAsync();
try
var baseUrl = string.IsNullOrEmpty(redirectUrl)
? HttpContext.GetExternalBaseUrl()
: redirectUrl.TrimEnd('/');
return $"{baseUrl}/api/account/oidc/link/callback";
}
private async Task<bool> IsOidcExclusiveModeActive()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is not { SetupCompleted: true })
{
var user = await GetCurrentUser();
if (user is null) return Unauthorized();
user.PlexAccountId = null;
user.PlexUsername = null;
user.PlexEmail = null;
user.PlexAuthToken = null;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
return Ok(new { message = "Plex account unlinked" });
}
finally
{
UsersContext.Lock.Release();
return false;
}
var oidc = user.Oidc;
return oidc is { Enabled: true, ExclusiveMode: true };
}
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using Cleanuparr.Api.Auth;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
@@ -19,25 +20,31 @@ namespace Cleanuparr.Api.Features.Auth.Controllers;
public sealed class AuthController : ControllerBase
{
private readonly UsersContext _usersContext;
private readonly DataContext _dataContext;
private readonly IJwtService _jwtService;
private readonly IPasswordService _passwordService;
private readonly ITotpService _totpService;
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AuthController> _logger;
public AuthController(
UsersContext usersContext,
DataContext dataContext,
IJwtService jwtService,
IPasswordService passwordService,
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AuthController> logger)
{
_usersContext = usersContext;
_dataContext = dataContext;
_jwtService = jwtService;
_passwordService = passwordService;
_totpService = totpService;
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
}
@@ -47,8 +54,7 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
var authBypass = false;
await using var dataContext = DataContext.CreateStaticInstance();
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
var generalConfig = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
{
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
@@ -60,11 +66,21 @@ public sealed class AuthController : ControllerBase
}
}
var oidcConfig = user?.Oidc;
var oidcEnabled = oidcConfig is { Enabled: true } &&
!string.IsNullOrEmpty(oidcConfig.IssuerUrl) &&
!string.IsNullOrEmpty(oidcConfig.ClientId);
var oidcExclusiveMode = oidcEnabled && oidcConfig!.ExclusiveMode;
return Ok(new AuthStatusResponse
{
SetupCompleted = user is { SetupCompleted: true },
PlexLinked = user?.PlexAccountId is not null,
AuthBypassActive = authBypass
AuthBypassActive = authBypass,
OidcEnabled = oidcEnabled,
OidcProviderName = oidcEnabled ? oidcConfig!.ProviderName : string.Empty,
OidcExclusiveMode = oidcExclusiveMode
});
}
@@ -241,8 +257,18 @@ public sealed class AuthController : ControllerBase
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
// Always verify the submitted password to prevent timing-based username enumeration
var userHasPassword = user?.PasswordHash is not null;
var passwordHash = user?.PasswordHash ?? _passwordService.DummyHash;
var passwordValid = _passwordService.VerifyPassword(request.Password, passwordHash) && userHasPassword;
if (user is null || !user.SetupCompleted)
{
return Unauthorized(new { error = "Invalid credentials" });
@@ -251,12 +277,11 @@ public sealed class AuthController : ControllerBase
// Check lockout
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
{
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds);
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
{
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
@@ -294,6 +319,11 @@ public sealed class AuthController : ControllerBase
[HttpPost("login/2fa")]
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
}
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
if (userId is null)
{
@@ -455,6 +485,11 @@ public sealed class AuthController : ControllerBase
[HttpPost("login/plex/pin")]
public async Task<IActionResult> RequestPlexPin()
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "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)
{
@@ -473,6 +508,11 @@ public sealed class AuthController : ControllerBase
[HttpPost("login/plex/verify")]
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "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)
{
@@ -509,6 +549,119 @@ public sealed class AuthController : ControllerBase
});
}
[HttpPost("oidc/start")]
public async Task<IActionResult> StartOidc()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
var oidcConfig = user?.Oidc;
if (oidcConfig is not { Enabled: true } ||
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
string.IsNullOrEmpty(oidcConfig.ClientId))
{
return BadRequest(new { error = "OIDC is not enabled or not configured" });
}
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
_logger.LogDebug("OIDC login start: using redirect URI {RedirectUri}", redirectUri);
try
{
var result = await _oidcAuthService.StartAuthorization(redirectUri);
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC authorization");
return StatusCode(429, new { error = ex.Message });
}
}
[HttpGet("oidc/callback")]
public async Task<IActionResult> OidcCallback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error)
{
var basePath = HttpContext.Request.GetSafeBasePath();
// Handle IdP error responses
if (!string.IsNullOrEmpty(error))
{
_logger.LogWarning("OIDC callback received error: {Error}", error);
return Redirect($"{basePath}/auth/login?oidc_error=provider_error");
}
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
return Redirect($"{basePath}/auth/login?oidc_error=invalid_request");
}
// Load the user early so we can use the configured redirect URL
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.SetupCompleted);
if (user is null)
{
return Redirect($"{basePath}/auth/login?oidc_error=no_account");
}
var redirectUri = GetOidcCallbackUrl(user.Oidc.RedirectUrl);
_logger.LogDebug("OIDC login callback: using redirect URI {RedirectUri}", redirectUri);
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
if (!result.Success)
{
_logger.LogWarning("OIDC callback failed: {Error}", result.Error);
return Redirect($"{basePath}/auth/login?oidc_error=authentication_failed");
}
if (!string.IsNullOrEmpty(user.Oidc.AuthorizedSubject) &&
result.Subject != user.Oidc.AuthorizedSubject)
{
_logger.LogWarning("OIDC subject mismatch. Expected: {Expected}, Got: {Got}",
user.Oidc.AuthorizedSubject, result.Subject);
return Redirect($"{basePath}/auth/login?oidc_error=unauthorized");
}
var tokenResponse = await GenerateTokenResponse(user);
// Store tokens with a one-time code (never put tokens in the URL)
var oneTimeCode = _oidcAuthService.StoreOneTimeCode(
tokenResponse.AccessToken,
tokenResponse.RefreshToken,
tokenResponse.ExpiresIn);
_logger.LogInformation("User {Username} authenticated via OIDC (subject: {Subject})",
user.Username, result.Subject);
return Redirect($"{basePath}/auth/oidc/callback?code={Uri.EscapeDataString(oneTimeCode)}");
}
[HttpPost("oidc/exchange")]
public IActionResult ExchangeOidcCode([FromBody] OidcExchangeRequest request)
{
var result = _oidcAuthService.ExchangeOneTimeCode(request.Code);
if (result is null)
{
return NotFound(new { error = "Invalid or expired code" });
}
return Ok(new TokenResponse
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
ExpiresIn = result.ExpiresIn
});
}
private string GetOidcCallbackUrl(string? redirectUrl = null)
{
var baseUrl = string.IsNullOrEmpty(redirectUrl)
? HttpContext.GetExternalBaseUrl()
: redirectUrl.TrimEnd('/');
return $"{baseUrl}/api/auth/oidc/callback";
}
private async Task<TokenResponse> GenerateTokenResponse(User user)
{
var accessToken = _jwtService.GenerateAccessToken(user);
@@ -610,4 +763,16 @@ public sealed class AuthController : ControllerBase
var hash = SHA256.HashData(bytes);
return Convert.ToBase64String(hash);
}
private async Task<bool> IsOidcExclusiveModeActive()
{
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is not { SetupCompleted: true })
{
return false;
}
var oidc = user.Oidc;
return oidc is { Enabled: true, ExclusiveMode: true };
}
}
@@ -2,7 +2,6 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
@@ -20,10 +19,6 @@ public sealed record UpdateGeneralConfigRequest
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
public bool SearchEnabled { get; init; } = true;
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; init; } = true;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
@@ -43,8 +38,6 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.HttpMaxRetries = HttpMaxRetries;
existingConfig.HttpTimeout = HttpTimeout;
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
@@ -63,14 +63,38 @@ public sealed class GeneralConfigController : ControllerBase
if (wasDryRun && !config.DryRun)
{
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
var deletedItems = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
await using var transaction = await eventsContext.Database.BeginTransactionAsync();
_logger.LogWarning(
"Dry run disabled — purged all strikes: {Strikes} strikes, {Items} download items removed",
deletedStrikes, deletedItems);
try
{
var deletedStrikes = await eventsContext.Strikes
.Where(s => s.IsDryRun)
.ExecuteDeleteAsync();
var deletedEvents = await eventsContext.Events
.Where(e => e.IsDryRun)
.ExecuteDeleteAsync();
var deletedManualEvents = await eventsContext.ManualEvents
.Where(e => e.IsDryRun)
.ExecuteDeleteAsync();
var deletedItems = await eventsContext.DownloadItems
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
var deletedHistory = await _dataContext.SeekerHistory
.Where(h => h.IsDryRun)
.ExecuteDeleteAsync();
_logger.LogWarning(
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed",
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
return Ok(new { Message = "General configuration updated successfully" });
@@ -17,4 +17,6 @@ public abstract record CreateNotificationProviderRequestBase
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
public bool OnSearchTriggered { get; init; }
}
@@ -17,4 +17,6 @@ public abstract record UpdateNotificationProviderRequestBase
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
public bool OnSearchTriggered { get; init; }
}
@@ -74,7 +74,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = p.OnSlowStrike,
OnQueueItemDeleted = p.OnQueueItemDeleted,
OnDownloadCleaned = p.OnDownloadCleaned,
OnCategoryChanged = p.OnCategoryChanged
OnCategoryChanged = p.OnCategoryChanged,
OnSearchTriggered = p.OnSearchTriggered
},
Configuration = p.Type switch
{
@@ -153,6 +154,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
NotifiarrConfiguration = notifiarrConfig
};
@@ -223,6 +225,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
AppriseConfiguration = appriseConfig
};
@@ -300,6 +303,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
NtfyConfiguration = ntfyConfig
};
@@ -368,6 +372,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
TelegramConfiguration = telegramConfig
};
@@ -447,6 +452,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -533,6 +539,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -622,6 +629,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
NtfyConfiguration = ntfyConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -705,6 +713,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
TelegramConfiguration = telegramConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -815,7 +824,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = notifiarrConfig
};
@@ -882,7 +892,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = appriseConfig
};
@@ -956,7 +967,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = ntfyConfig
};
@@ -1013,7 +1025,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = telegramConfig
};
@@ -1048,7 +1061,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
OnCategoryChanged = provider.OnCategoryChanged
OnCategoryChanged = provider.OnCategoryChanged,
OnSearchTriggered = provider.OnSearchTriggered
},
Configuration = provider.Type switch
{
@@ -1105,6 +1119,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
DiscordConfiguration = discordConfig
};
@@ -1185,6 +1200,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1254,7 +1270,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = discordConfig
};
@@ -1325,6 +1342,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
PushoverConfiguration = pushoverConfig
};
@@ -1412,6 +1430,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
PushoverConfiguration = pushoverConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1495,7 +1514,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = pushoverConfig
};
@@ -1551,6 +1571,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
OnSearchTriggered = newProvider.OnSearchTriggered,
GotifyConfiguration = gotifyConfig
};
@@ -1631,6 +1652,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
OnSearchTriggered = updatedProvider.OnSearchTriggered,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1698,7 +1720,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
OnCategoryChanged = false,
OnSearchTriggered = false
},
Configuration = gotifyConfig
};
@@ -0,0 +1,42 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
public sealed record UpdateSeekerConfigRequest
{
public bool SearchEnabled { get; init; } = true;
public ushort SearchInterval { get; init; } = 3;
public bool ProactiveSearchEnabled { get; init; }
public SelectionStrategy SelectionStrategy { get; init; } = SelectionStrategy.BalancedWeighted;
public bool MonitoredOnly { get; init; } = true;
public bool UseCutoff { get; init; }
public bool UseCustomFormatScore { get; init; }
public bool UseRoundRobin { get; init; } = true;
public int PostReleaseGraceHours { get; init; } = 6;
public List<UpdateSeekerInstanceConfigRequest> Instances { get; init; } = [];
public SeekerConfig ApplyTo(SeekerConfig config)
{
config.SearchEnabled = SearchEnabled;
config.SearchInterval = SearchInterval;
config.ProactiveSearchEnabled = ProactiveSearchEnabled;
config.SelectionStrategy = SelectionStrategy;
config.MonitoredOnly = MonitoredOnly;
config.UseCutoff = UseCutoff;
config.UseCustomFormatScore = UseCustomFormatScore;
config.UseRoundRobin = UseRoundRobin;
config.PostReleaseGraceHours = PostReleaseGraceHours;
return config;
}
}
@@ -0,0 +1,14 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
public sealed record UpdateSeekerInstanceConfigRequest
{
public Guid ArrInstanceId { get; init; }
public bool Enabled { get; init; } = true;
public List<string> SkipTags { get; init; } = [];
public int ActiveDownloadLimit { get; init; } = 3;
public int MinCycleTimeDays { get; init; } = 7;
}
@@ -0,0 +1,20 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreEntryResponse
{
public Guid Id { get; init; }
public Guid ArrInstanceId { get; init; }
public long ExternalItemId { get; init; }
public long EpisodeId { get; init; }
public InstanceType ItemType { get; init; }
public string Title { get; init; } = string.Empty;
public long FileId { get; init; }
public int CurrentScore { get; init; }
public int CutoffScore { get; init; }
public string QualityProfileName { get; init; } = string.Empty;
public bool IsBelowCutoff { get; init; }
public bool IsMonitored { get; init; }
public DateTime LastSyncedAt { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreHistoryEntryResponse
{
public int Score { get; init; }
public int CutoffScore { get; init; }
public DateTime RecordedAt { get; init; }
}
@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreStatsResponse
{
public int TotalTracked { get; init; }
public int BelowCutoff { get; init; }
public int AtOrAboveCutoff { get; init; }
public int Monitored { get; init; }
public int Unmonitored { get; init; }
public int RecentUpgrades { get; init; }
public List<InstanceCfScoreStat> PerInstanceStats { get; init; } = [];
}
public sealed record InstanceCfScoreStat
{
public Guid InstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public string InstanceType { get; init; } = string.Empty;
public int TotalTracked { get; init; }
public int BelowCutoff { get; init; }
public int AtOrAboveCutoff { get; init; }
public int Monitored { get; init; }
public int Unmonitored { get; init; }
public int RecentUpgrades { get; init; }
}
@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record CustomFormatScoreUpgradeResponse
{
public Guid ArrInstanceId { get; init; }
public long ExternalItemId { get; init; }
public long EpisodeId { get; init; }
public InstanceType ItemType { get; init; }
public string Title { get; init; } = string.Empty;
public int PreviousScore { get; init; }
public int NewScore { get; init; }
public int CutoffScore { get; init; }
public DateTime UpgradedAt { get; init; }
}
@@ -0,0 +1,16 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record InstanceSearchStat
{
public Guid InstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
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 Guid? CurrentCycleId { get; init; }
public int CycleItemsSearched { get; init; }
public int CycleItemsTotal { get; init; }
public DateTime? CycleStartedAt { get; init; }
}
@@ -0,0 +1,19 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SearchEventResponse
{
public Guid Id { get; init; }
public DateTime Timestamp { get; init; }
public string InstanceName { get; init; } = string.Empty;
public string? InstanceType { get; init; }
public int ItemCount { get; init; }
public List<string> Items { get; init; } = [];
public SeekerSearchType SearchType { get; init; }
public SearchCommandStatus? SearchStatus { get; init; }
public DateTime? CompletedAt { get; init; }
public object? GrabbedItems { get; init; }
public Guid? CycleId { get; init; }
public bool IsDryRun { get; init; }
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SearchStatsSummaryResponse
{
public int TotalSearchesAllTime { get; init; }
public int SearchesLast7Days { get; init; }
public int SearchesLast30Days { get; init; }
public int UniqueItemsSearched { get; init; }
public int PendingReplacementSearches { get; init; }
public int EnabledInstances { get; init; }
public List<InstanceSearchStat> PerInstanceStats { get; init; } = [];
}
@@ -0,0 +1,26 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SeekerConfigResponse
{
public bool SearchEnabled { get; init; }
public ushort SearchInterval { get; init; }
public bool ProactiveSearchEnabled { get; init; }
public SelectionStrategy SelectionStrategy { get; init; }
public bool MonitoredOnly { get; init; }
public bool UseCutoff { get; init; }
public bool UseCustomFormatScore { get; init; }
public bool UseRoundRobin { get; init; }
public int PostReleaseGraceHours { get; init; }
public List<SeekerInstanceConfigResponse> Instances { get; init; } = [];
}
@@ -0,0 +1,24 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
public sealed record SeekerInstanceConfigResponse
{
public Guid ArrInstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public InstanceType InstanceType { get; init; }
public bool Enabled { get; init; }
public List<string> SkipTags { get; init; } = [];
public DateTime? LastProcessedAt { get; init; }
public bool ArrInstanceEnabled { get; init; }
public int ActiveDownloadLimit { get; init; }
public int MinCycleTimeDays { get; init; }
}
@@ -0,0 +1,311 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/seeker/cf-scores")]
[Authorize]
public sealed class CustomFormatScoreController : ControllerBase
{
private readonly DataContext _dataContext;
public CustomFormatScoreController(DataContext dataContext)
{
_dataContext = dataContext;
}
/// <summary>
/// Gets current CF scores with pagination, optionally filtered by instance.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetCustomFormatScores(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] string? search = null,
[FromQuery] string sortBy = "title",
[FromQuery] bool hideMet = false)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.AsQueryable();
if (instanceId.HasValue)
{
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
}
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(e => e.Title.ToLower().Contains(search.ToLower()));
}
if (hideMet)
{
query = query.Where(e => e.CurrentScore < e.CutoffScore);
}
int totalCount = await query.CountAsync();
var items = await (sortBy == "date"
? query.OrderByDescending(e => e.LastSyncedAt)
: query.OrderBy(e => e.Title))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(e => new CustomFormatScoreEntryResponse
{
Id = e.Id,
ArrInstanceId = e.ArrInstanceId,
ExternalItemId = e.ExternalItemId,
EpisodeId = e.EpisodeId,
ItemType = e.ItemType,
Title = e.Title,
FileId = e.FileId,
CurrentScore = e.CurrentScore,
CutoffScore = e.CutoffScore,
QualityProfileName = e.QualityProfileName,
IsBelowCutoff = e.CurrentScore < e.CutoffScore,
IsMonitored = e.IsMonitored,
LastSyncedAt = e.LastSyncedAt,
})
.ToListAsync();
return Ok(new
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
/// <summary>
/// Gets recent CF score upgrades (where score improved in history).
/// </summary>
[HttpGet("upgrades")]
public async Task<IActionResult> GetRecentUpgrades(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? instanceId = null,
[FromQuery] int days = 30)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 100) pageSize = 100;
// Find history entries where a newer entry has a higher score than an older one
// We group by item and look for score increases between consecutive records
var query = _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.AsQueryable();
if (instanceId.HasValue)
{
query = query.Where(h => h.ArrInstanceId == instanceId.Value);
}
var allHistory = await query
.Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days))
.OrderByDescending(h => h.RecordedAt)
.ToListAsync();
var upgrades = new List<CustomFormatScoreUpgradeResponse>();
// Group by (ArrInstanceId, ExternalItemId, EpisodeId) and find score increases
var grouped = allHistory
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
foreach (var group in grouped)
{
var entries = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < entries.Count; i++)
{
if (entries[i].Score > entries[i - 1].Score)
{
upgrades.Add(new CustomFormatScoreUpgradeResponse
{
ArrInstanceId = entries[i].ArrInstanceId,
ExternalItemId = entries[i].ExternalItemId,
EpisodeId = entries[i].EpisodeId,
ItemType = entries[i].ItemType,
Title = entries[i].Title,
PreviousScore = entries[i - 1].Score,
NewScore = entries[i].Score,
CutoffScore = entries[i].CutoffScore,
UpgradedAt = entries[i].RecordedAt,
});
}
}
}
// Sort by most recent upgrade first
upgrades = upgrades.OrderByDescending(u => u.UpgradedAt).ToList();
int totalCount = upgrades.Count;
var paged = upgrades
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Ok(new
{
Items = paged,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
[HttpGet("instances")]
public async Task<IActionResult> GetInstances()
{
var instances = await _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.Select(e => new { e.ArrInstanceId, e.ItemType })
.Distinct()
.Join(
_dataContext.ArrInstances.AsNoTracking(),
e => e.ArrInstanceId,
a => a.Id,
(e, a) => new
{
Id = e.ArrInstanceId,
a.Name,
e.ItemType,
})
.OrderBy(x => x.Name)
.ToListAsync();
return Ok(new { Instances = instances });
}
/// <summary>
/// Gets summary statistics for CF score tracking.
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
{
var entries = await _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.ToListAsync();
int totalTracked = entries.Count;
int belowCutoff = entries.Count(e => e.CurrentScore < e.CutoffScore);
int atOrAboveCutoff = totalTracked - belowCutoff;
int monitored = entries.Count(e => e.IsMonitored);
int unmonitored = totalTracked - monitored;
// Count upgrades in the last 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var recentHistory = await _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.Where(h => h.RecordedAt >= sevenDaysAgo)
.OrderBy(h => h.RecordedAt)
.ToListAsync();
int recentUpgrades = 0;
var recentGrouped = recentHistory
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
foreach (var group in recentGrouped)
{
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < ordered.Count; i++)
{
if (ordered[i].Score > ordered[i - 1].Score)
recentUpgrades++;
}
}
// Per-instance stats
var instanceIds = entries.Select(e => e.ArrInstanceId).Distinct().ToList();
var instances = await _dataContext.ArrInstances
.AsNoTracking()
.Include(a => a.ArrConfig)
.Where(a => instanceIds.Contains(a.Id))
.ToListAsync();
var perInstanceStats = instanceIds.Select(instanceId =>
{
var instanceEntries = entries.Where(e => e.ArrInstanceId == instanceId).ToList();
int instTracked = instanceEntries.Count;
int instBelow = instanceEntries.Count(e => e.CurrentScore < e.CutoffScore);
int instMonitored = instanceEntries.Count(e => e.IsMonitored);
int instUpgrades = 0;
var instHistory = recentGrouped
.Where(g => g.Key.ArrInstanceId == instanceId);
foreach (var group in instHistory)
{
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < ordered.Count; i++)
{
if (ordered[i].Score > ordered[i - 1].Score)
instUpgrades++;
}
}
var instance = instances.FirstOrDefault(a => a.Id == instanceId);
return new InstanceCfScoreStat
{
InstanceId = instanceId,
InstanceName = instance?.Name ?? "Unknown",
InstanceType = instance?.ArrConfig.Type.ToString() ?? "Unknown",
TotalTracked = instTracked,
BelowCutoff = instBelow,
AtOrAboveCutoff = instTracked - instBelow,
Monitored = instMonitored,
Unmonitored = instTracked - instMonitored,
RecentUpgrades = instUpgrades,
};
}).OrderBy(s => s.InstanceName).ToList();
return Ok(new CustomFormatScoreStatsResponse
{
TotalTracked = totalTracked,
BelowCutoff = belowCutoff,
AtOrAboveCutoff = atOrAboveCutoff,
Monitored = monitored,
Unmonitored = unmonitored,
RecentUpgrades = recentUpgrades,
PerInstanceStats = perInstanceStats,
});
}
/// <summary>
/// Gets CF score history for a specific item.
/// </summary>
[HttpGet("{instanceId}/{itemId}/history")]
public async Task<IActionResult> GetItemHistory(
Guid instanceId,
long itemId,
[FromQuery] long episodeId = 0)
{
var history = await _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.Where(h => h.ArrInstanceId == instanceId
&& h.ExternalItemId == itemId
&& h.EpisodeId == episodeId)
.OrderByDescending(h => h.RecordedAt)
.Select(h => new CustomFormatScoreHistoryEntryResponse
{
Score = h.Score,
CutoffScore = h.CutoffScore,
RecordedAt = h.RecordedAt,
})
.ToListAsync();
return Ok(new { Entries = history });
}
}
@@ -0,0 +1,250 @@
using System.Text.Json;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/seeker/search-stats")]
[Authorize]
public sealed class SearchStatsController : ControllerBase
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
public SearchStatsController(DataContext dataContext, EventsContext eventsContext)
{
_dataContext = dataContext;
_eventsContext = eventsContext;
}
/// <summary>
/// Gets aggregate search statistics across all instances.
/// </summary>
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
// Event counts from EventsContext
var searchEvents = _eventsContext.Events
.AsNoTracking()
.Where(e => e.EventType == EventType.SearchTriggered);
int totalSearchesAllTime = await searchEvents.CountAsync();
int searchesLast7Days = await searchEvents.CountAsync(e => e.Timestamp >= sevenDaysAgo);
int searchesLast30Days = await searchEvents.CountAsync(e => e.Timestamp >= thirtyDaysAgo);
// History stats from DataContext
int uniqueItemsSearched = await _dataContext.SeekerHistory
.AsNoTracking()
.Select(h => h.ExternalItemId)
.Distinct()
.CountAsync();
int pendingReplacementSearches = await _dataContext.SearchQueue.CountAsync();
// Per-instance stats
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
.AsNoTracking()
.Include(s => s.ArrInstance)
.ThenInclude(a => a.ArrConfig)
.Where(s => s.Enabled && s.ArrInstance.Enabled)
.ToListAsync();
var historyByInstance = await _dataContext.SeekerHistory
.AsNoTracking()
.GroupBy(h => h.ArrInstanceId)
.Select(g => new
{
InstanceId = g.Key,
ItemsTracked = g.Select(h => h.ExternalItemId).Distinct().Count(),
LastSearchedAt = g.Max(h => h.LastSearchedAt),
TotalSearchCount = g.Sum(h => h.SearchCount),
})
.ToListAsync();
// Count items searched in current cycle per instance
List<Guid> currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
var cycleItemsByInstance = await _dataContext.SeekerHistory
.AsNoTracking()
.Where(h => currentCycleIds.Contains(h.CycleId))
.GroupBy(h => h.ArrInstanceId)
.Select(g => new
{
InstanceId = g.Key,
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
})
.ToListAsync();
var perInstanceStats = instanceConfigs.Select(ic =>
{
var history = historyByInstance.FirstOrDefault(h => h.InstanceId == ic.ArrInstanceId);
var cycleProgress = cycleItemsByInstance.FirstOrDefault(c => c.InstanceId == ic.ArrInstanceId);
return new InstanceSearchStat
{
InstanceId = ic.ArrInstanceId,
InstanceName = ic.ArrInstance.Name,
InstanceType = ic.ArrInstance.ArrConfig.Type.ToString(),
ItemsTracked = history?.ItemsTracked ?? 0,
TotalSearchCount = history?.TotalSearchCount ?? 0,
LastSearchedAt = history?.LastSearchedAt,
LastProcessedAt = ic.LastProcessedAt,
CurrentCycleId = ic.CurrentCycleId,
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
CycleItemsTotal = ic.TotalEligibleItems,
CycleStartedAt = cycleProgress?.CycleStartedAt,
};
}).ToList();
return Ok(new SearchStatsSummaryResponse
{
TotalSearchesAllTime = totalSearchesAllTime,
SearchesLast7Days = searchesLast7Days,
SearchesLast30Days = searchesLast30Days,
UniqueItemsSearched = uniqueItemsSearched,
PendingReplacementSearches = pendingReplacementSearches,
EnabledInstances = instanceConfigs.Count,
PerInstanceStats = perInstanceStats,
});
}
/// <summary>
/// Gets paginated search-triggered events with decoded data.
/// Supports optional text search across item names in event data.
/// </summary>
[HttpGet("events")]
public async Task<IActionResult> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] Guid? cycleId = null,
[FromQuery] string? search = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _eventsContext.Events
.AsNoTracking()
.Where(e => e.EventType == EventType.SearchTriggered);
// Filter by instance URL if instanceId provided
if (instanceId.HasValue)
{
var instance = await _dataContext.ArrInstances
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == instanceId.Value);
if (instance is not null)
{
string url = (instance.ExternalUrl ?? instance.Url).ToString();
query = query.Where(e => e.InstanceUrl == url);
}
}
// Filter by cycle ID
if (cycleId.HasValue)
{
query = query.Where(e => e.CycleId == cycleId.Value);
}
// Pre-filter by search term on the JSON data field
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(e => e.Data != null && e.Data.ToLower().Contains(search.ToLower()));
}
int totalCount = await query.CountAsync();
var rawEvents = await query
.OrderByDescending(e => e.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var items = rawEvents.Select(e =>
{
var parsed = ParseEventData(e.Data);
return new SearchEventResponse
{
Id = e.Id,
Timestamp = e.Timestamp,
InstanceName = parsed.InstanceName,
InstanceType = e.InstanceType?.ToString(),
ItemCount = parsed.ItemCount,
Items = parsed.Items,
SearchType = parsed.SearchType,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
GrabbedItems = parsed.GrabbedItems,
CycleId = e.CycleId,
IsDryRun = e.IsDryRun,
};
}).ToList();
return Ok(new
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
private static (string InstanceName, int ItemCount, List<string> Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data)
{
if (string.IsNullOrWhiteSpace(data))
{
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
}
try
{
using JsonDocument doc = JsonDocument.Parse(data);
JsonElement root = doc.RootElement;
string instanceName = root.TryGetProperty("InstanceName", out var nameEl)
? nameEl.GetString() ?? "Unknown"
: "Unknown";
int itemCount = root.TryGetProperty("ItemCount", out var countEl)
? countEl.GetInt32()
: 0;
var items = new List<string>();
if (root.TryGetProperty("Items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in itemsEl.EnumerateArray())
{
string? val = item.GetString();
if (val is not null) items.Add(val);
}
}
SeekerSearchType searchType = root.TryGetProperty("SearchType", out var typeEl)
&& Enum.TryParse<SeekerSearchType>(typeEl.GetString(), out var parsed)
? parsed
: SeekerSearchType.Proactive;
object? grabbedItems = root.TryGetProperty("GrabbedItems", out var grabbedEl)
? JsonSerializer.Deserialize<object>(grabbedEl.GetRawText())
: null;
return (instanceName, itemCount, items, searchType, grabbedItems);
}
catch (JsonException)
{
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
}
}
}
@@ -0,0 +1,206 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class SeekerConfigController : ControllerBase
{
private readonly ILogger<SeekerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public SeekerConfigController(
ILogger<SeekerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("seeker")]
public async Task<IActionResult> GetSeekerConfig()
{
var config = await _dataContext.SeekerConfigs
.AsNoTracking()
.FirstAsync();
// Get all Sonarr/Radarr instances with their seeker configs
var arrInstances = await _dataContext.ArrInstances
.AsNoTracking()
.Include(a => a.ArrConfig)
.Where(a => a.ArrConfig.Type == InstanceType.Sonarr || a.ArrConfig.Type == InstanceType.Radarr)
.ToListAsync();
var arrInstanceIds = arrInstances.Select(a => a.Id).ToHashSet();
var seekerInstanceConfigs = await _dataContext.SeekerInstanceConfigs
.AsNoTracking()
.Where(s => arrInstanceIds.Contains(s.ArrInstanceId))
.ToListAsync();
var instanceResponses = arrInstances.Select(instance =>
{
var seekerConfig = seekerInstanceConfigs.FirstOrDefault(s => s.ArrInstanceId == instance.Id);
return new SeekerInstanceConfigResponse
{
ArrInstanceId = instance.Id,
InstanceName = instance.Name,
InstanceType = instance.ArrConfig.Type,
Enabled = seekerConfig?.Enabled ?? false,
SkipTags = seekerConfig?.SkipTags ?? [],
LastProcessedAt = seekerConfig?.LastProcessedAt,
ArrInstanceEnabled = instance.Enabled,
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3,
MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 7,
};
}).ToList();
var response = new SeekerConfigResponse
{
SearchEnabled = config.SearchEnabled,
SearchInterval = config.SearchInterval,
ProactiveSearchEnabled = config.ProactiveSearchEnabled,
SelectionStrategy = config.SelectionStrategy,
MonitoredOnly = config.MonitoredOnly,
UseCutoff = config.UseCutoff,
UseCustomFormatScore = config.UseCustomFormatScore,
UseRoundRobin = config.UseRoundRobin,
PostReleaseGraceHours = config.PostReleaseGraceHours,
Instances = instanceResponses,
};
return Ok(response);
}
[HttpPut("seeker")]
public async Task<IActionResult> UpdateSeekerConfig([FromBody] UpdateSeekerConfigRequest request)
{
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
{
return StatusCode(503, "Database is busy, please try again");
}
try
{
var config = await _dataContext.SeekerConfigs.FirstAsync();
ushort previousInterval = config.SearchInterval;
bool previousUseCustomFormatScore = config.UseCustomFormatScore;
bool previousSearchEnabled = config.SearchEnabled;
bool previousProactiveSearchEnabled = config.ProactiveSearchEnabled;
request.ApplyTo(config);
config.Validate();
if (request.ProactiveSearchEnabled && !request.Instances.Any(i => i.Enabled))
{
throw new Domain.Exceptions.ValidationException(
"At least one instance must be enabled when proactive search is enabled");
}
// Sync instance configs
var existingInstanceConfigs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
foreach (var instanceReq in request.Instances)
{
var existing = existingInstanceConfigs
.FirstOrDefault(e => e.ArrInstanceId == instanceReq.ArrInstanceId);
if (existing is not null)
{
existing.Enabled = instanceReq.Enabled;
existing.SkipTags = instanceReq.SkipTags;
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays;
}
else
{
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = instanceReq.ArrInstanceId,
Enabled = instanceReq.Enabled,
SkipTags = instanceReq.SkipTags,
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
MinCycleTimeDays = instanceReq.MinCycleTimeDays,
});
}
}
await _dataContext.SaveChangesAsync();
// Start/stop Seeker based on SearchEnabled toggle
if (config.SearchEnabled != previousSearchEnabled)
{
if (config.SearchEnabled)
{
_logger.LogInformation("SearchEnabled turned on, starting Seeker job");
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
}
else
{
_logger.LogInformation("SearchEnabled turned off, stopping Seeker job");
await _jobManagementService.StopJob(JobType.Seeker);
}
}
// Update Quartz trigger if SearchInterval changed (only while search is enabled)
else if (config.SearchEnabled && config.SearchInterval != previousInterval)
{
_logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule",
previousInterval, config.SearchInterval);
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
}
// Toggle CustomFormatScoreSyncer job when UseCustomFormatScore changes
if (config.UseCustomFormatScore != previousUseCustomFormatScore)
{
if (config.UseCustomFormatScore)
{
_logger.LogInformation("UseCustomFormatScore enabled, starting CustomFormatScoreSyncer job");
await _jobManagementService.StartJob(JobType.CustomFormatScoreSyncer, null, Constants.CustomFormatScoreSyncerCron);
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
else
{
_logger.LogInformation("UseCustomFormatScore disabled, stopping CustomFormatScoreSyncer job");
await _jobManagementService.StopJob(JobType.CustomFormatScoreSyncer);
}
}
// Trigger CustomFormatScoreSyncer once when search or proactive search is re-enabled with custom format scores active
if (previousUseCustomFormatScore && config.UseCustomFormatScore)
{
bool searchJustEnabled = !previousSearchEnabled && config.SearchEnabled;
bool proactiveJustEnabled = !previousProactiveSearchEnabled && config.ProactiveSearchEnabled;
if (searchJustEnabled || proactiveJustEnabled)
{
_logger.LogInformation("Search re-enabled with UseCustomFormatScore active, triggering CustomFormatScoreSyncer");
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
}
return Ok(new { Message = "Seeker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Seeker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -1,6 +1,4 @@
using System.Reflection;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -6,6 +6,8 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Quartz;
@@ -100,12 +102,17 @@ public class BackgroundJobManager : IHostedService
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
SeekerConfig seekerConfig = await dataContext.SeekerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
await RegisterSeekerJob(seekerConfig, cancellationToken);
await RegisterCustomFormatScoreSyncJob(seekerConfig, cancellationToken);
}
/// <summary>
@@ -171,6 +178,33 @@ public class BackgroundJobManager : IHostedService
}
}
/// <summary>
/// Registers the Seeker job with a trigger based on SearchInterval.
/// The Seeker is always running.
/// </summary>
public async Task RegisterSeekerJob(SeekerConfig config, CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<SeekerJob>(cancellationToken);
if (config.SearchEnabled)
{
await AddTriggersForJob<SeekerJob>(config.ToCronExpression(), cancellationToken);
}
}
/// <summary>
/// Registers the CustomFormatScoreSyncer job. Only adds triggers when UseCustomFormatScore is enabled.
/// Runs every 30 minutes to sync custom format scores from arr instances.
/// </summary>
public async Task RegisterCustomFormatScoreSyncJob(SeekerConfig config, CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<CustomFormatScoreSyncer>(cancellationToken);
if (config.UseCustomFormatScore)
{
await AddTriggersForJob<CustomFormatScoreSyncer>(Constants.CustomFormatScoreSyncerCron, cancellationToken);
}
}
/// <summary>
/// Helper method to add triggers for an existing job.
/// </summary>
@@ -204,7 +238,11 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
if (typeof(T) == typeof(SeekerJob) && triggerValue < Constants.SeekerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes");
}
else if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}
@@ -51,7 +51,7 @@ public sealed class GenericJob<T> : IJob
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await handler.ExecuteAsync(context.CancellationToken);
status = JobRunStatus.Completed;
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
@@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler
_dryRunInterceptor = dryRunInterceptor;
}
public async Task ExecuteAsync()
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
@@ -73,7 +73,7 @@ public sealed class BlacklistSynchronizer : IHandler
.AsNoTracking()
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
.ToListAsync();
if (qBittorrentClients.Count is 0)
{
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
@@ -0,0 +1,3 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrCommandStatus(long Id, string Status, string? Message);
@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrEpisodeFile
{
public long Id { get; init; }
public bool QualityCutoffNotMet { get; init; }
public int CustomFormatScore { get; init; }
}
@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record ArrQualityProfile
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public int CutoffFormatScore { get; init; }
}
@@ -0,0 +1,11 @@
namespace Cleanuparr.Domain.Entities.Arr;
/// <summary>
/// Represents the custom format score data from a movie/episode file API response
/// </summary>
public sealed record MediaFileScore
{
public long Id { get; init; }
public int CustomFormatScore { get; init; }
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record MovieFileInfo
{
public long Id { get; init; }
public bool QualityCutoffNotMet { get; init; }
}
@@ -37,4 +37,5 @@ public sealed record QueueRecord
public required string DownloadId { get; init; }
public required string Protocol { get; init; }
public required long Id { get; init; }
public long SizeLeft { get; init; }
}
@@ -0,0 +1,18 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SearchableEpisode
{
public long Id { get; init; }
public int SeasonNumber { get; init; }
public int EpisodeNumber { get; init; }
public bool Monitored { get; init; }
public DateTime? AirDateUtc { get; init; }
public bool HasFile { get; init; }
public long EpisodeFileId { get; init; }
}
@@ -0,0 +1,28 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SearchableMovie
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public bool Monitored { get; init; }
public bool HasFile { get; init; }
public MovieFileInfo? MovieFile { get; init; }
public List<string> Tags { get; init; } = [];
public int QualityProfileId { get; init; }
public string Status { get; init; } = string.Empty;
public DateTime? Added { get; init; }
public DateTime? DigitalRelease { get; init; }
public DateTime? PhysicalRelease { get; init; }
public DateTime? InCinemas { get; init; }
}
@@ -0,0 +1,29 @@
namespace Cleanuparr.Domain.Entities.Arr;
public sealed record SearchableSeries
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public int QualityProfileId { get; init; }
public bool Monitored { get; init; }
public List<string> Tags { get; init; } = [];
public DateTime? Added { get; init; }
public string Status { get; init; } = string.Empty;
public SeriesStatistics? Statistics { get; init; }
}
public sealed record SeriesStatistics
{
public int EpisodeFileCount { get; init; }
public int EpisodeCount { get; init; }
public double PercentOfEpisodes { get; init; }
}
@@ -10,5 +10,6 @@ public enum EventType
QueueItemDeleted,
DownloadCleaned,
CategoryChanged,
DownloadMarkedForDeletion
DownloadMarkedForDeletion,
SearchTriggered,
}
@@ -6,4 +6,6 @@ public enum JobType
MalwareBlocker,
DownloadCleaner,
BlacklistSynchronizer,
Seeker,
CustomFormatScoreSyncer,
}
@@ -9,5 +9,6 @@ public enum NotificationEventType
SlowTimeStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged
CategoryChanged,
SearchTriggered
}
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SearchCommandStatus
{
Pending,
Started,
Completed,
Failed,
TimedOut
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeekerSearchType
{
Proactive,
Replacement
}
@@ -0,0 +1,43 @@
namespace Cleanuparr.Domain.Enums;
public enum SelectionStrategy
{
/// <summary>
/// Weighted random selection combining search recency and add date.
/// Items that are both recently added and haven't been searched
/// get the highest priority. Best all-around strategy for mixed libraries.
/// </summary>
BalancedWeighted,
/// <summary>
/// Deterministic selection of items with the oldest (or no) search history first.
/// Provides systematic, sequential coverage of your entire library.
/// </summary>
OldestSearchFirst,
/// <summary>
/// Weighted random selection based on search recency.
/// Items that haven't been searched recently are ranked higher and more likely to be selected,
/// while recently-searched items still have a chance proportional to their rank.
/// </summary>
OldestSearchWeighted,
/// <summary>
/// Deterministic selection of the most recently added items first.
/// Always picks the newest content in your library.
/// </summary>
NewestFirst,
/// <summary>
/// Weighted random selection based on when items were added.
/// Recently added items are ranked higher and more likely to be selected,
/// while older items still have a chance proportional to their rank.
/// </summary>
NewestWeighted,
/// <summary>
/// Pure random selection with no weighting or bias.
/// Every eligible item has an equal chance of being selected.
/// </summary>
Random,
}
@@ -1,8 +1,8 @@
namespace Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Enums;
public enum SeriesSearchType
{
Episode,
Season,
Series
}
}
@@ -9,4 +9,8 @@ public sealed class ValidationException : Exception
public ValidationException(string message) : base(message)
{
}
public ValidationException(string message, Exception inner) : base(message, inner)
{
}
}
@@ -1,3 +1,4 @@
using System.Text.Json;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Context;
@@ -45,19 +46,8 @@ public class EventPublisherTests : IDisposable
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
// Setup dry run interceptor to execute the delegate
_dryRunInterceptorMock.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns<Delegate, object[]>(async (del, args) =>
{
if (del is Func<AppEvent, Task> func && args.Length > 0 && args[0] is AppEvent appEvent)
{
await func(appEvent);
}
else if (del is Func<ManualEvent, Task> manualFunc && args.Length > 0 && args[0] is ManualEvent manualEvent)
{
await manualFunc(manualEvent);
}
});
// Setup dry run interceptor to report dry run as disabled by default
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
_publisher = new EventPublisher(
_context,
@@ -251,10 +241,10 @@ public class EventPublisherTests : IDisposable
#endregion
#region DryRun Interceptor Tests
#region DryRun Tests
[Fact]
public async Task PublishAsync_UsesDryRunInterceptor()
public async Task PublishAsync_ChecksDryRunStatus()
{
// Arrange
var eventType = EventType.StalledStrike;
@@ -265,13 +255,11 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishAsync(eventType, message, severity);
// Assert
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
It.IsAny<Delegate>(),
It.IsAny<object[]>()), Times.Once);
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
}
[Fact]
public async Task PublishManualAsync_UsesDryRunInterceptor()
public async Task PublishManualAsync_ChecksDryRunStatus()
{
// Arrange
var message = "Manual test";
@@ -281,9 +269,77 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishManualAsync(message, severity);
// Assert
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
It.IsAny<Delegate>(),
It.IsAny<object[]>()), Times.Once);
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
}
[Fact]
public async Task PublishAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
{
// Arrange
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
var eventType = EventType.StalledStrike;
var message = "Dry run event";
var severity = EventSeverity.Warning;
// Act
await _publisher.PublishAsync(eventType, message, severity);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.True(savedEvent.IsDryRun);
}
[Fact]
public async Task PublishAsync_WhenDryRunDisabled_SetsIsDryRunFalse()
{
// Arrange
var eventType = EventType.QueueItemDeleted;
var message = "Normal event";
var severity = EventSeverity.Important;
// Act
await _publisher.PublishAsync(eventType, message, severity);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.False(savedEvent.IsDryRun);
}
[Fact]
public async Task PublishManualAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
{
// Arrange
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
var message = "Dry run manual event";
var severity = EventSeverity.Important;
// Act
await _publisher.PublishManualAsync(message, severity);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.True(savedEvent.IsDryRun);
}
[Fact]
public async Task PublishAsync_WhenDryRunEnabled_StillSavesToDatabase()
{
// Arrange
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
var eventType = EventType.StalledStrike;
var message = "Should be saved";
var severity = EventSeverity.Warning;
// Act
await _publisher.PublishAsync(eventType, message, severity);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(message, savedEvent.Message);
}
#endregion
@@ -523,4 +579,220 @@ public class EventPublisherTests : IDisposable
}
#endregion
#region PublishSearchTriggered Tests
[Fact]
public async Task PublishSearchTriggered_SavesEventWithCorrectType()
{
// Act
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(EventType.SearchTriggered, savedEvent.EventType);
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
}
[Fact]
public async Task PublishSearchTriggered_SetsSearchStatusToPending()
{
// Act
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(SearchCommandStatus.Pending, savedEvent.SearchStatus);
}
[Fact]
public async Task PublishSearchTriggered_SetsCycleId()
{
// Arrange
var cycleId = Guid.NewGuid();
// Act
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive, cycleId);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Equal(cycleId, savedEvent.CycleId);
}
[Fact]
public async Task PublishSearchTriggered_ReturnsEventId()
{
// Act
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
// Assert
Assert.NotEqual(Guid.Empty, eventId);
var savedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(savedEvent);
}
[Fact]
public async Task PublishSearchTriggered_SerializesItemsAndSearchTypeToData()
{
// Act
await _publisher.PublishSearchTriggered("Sonarr-1", 2, ["Series A", "Series B"], SeekerSearchType.Replacement);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.NotNull(savedEvent.Data);
Assert.Contains("Series A", savedEvent.Data);
Assert.Contains("Series B", savedEvent.Data);
Assert.Contains("Replacement", savedEvent.Data);
Assert.Contains("Sonarr-1", savedEvent.Data);
}
[Fact]
public async Task PublishSearchTriggered_NotifiesSignalRClients()
{
// Act
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
// Assert
_clientProxyMock.Verify(c => c.SendCoreAsync(
"EventReceived",
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task PublishSearchTriggered_SendsNotification()
{
// Act
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
// Assert
_notificationPublisherMock.Verify(
n => n.NotifySearchTriggered("Radarr-1", 2, It.IsAny<IEnumerable<string>>()),
Times.Once);
}
[Fact]
public async Task PublishSearchTriggered_TruncatesDisplayForMoreThan5Items()
{
// Arrange
var items = new[] { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7" };
// Act
await _publisher.PublishSearchTriggered("Radarr-1", 7, items, SeekerSearchType.Proactive);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
Assert.NotNull(savedEvent);
Assert.Contains("+2 more", savedEvent.Message);
}
#endregion
#region PublishSearchCompleted Tests
[Fact]
public async Task PublishSearchCompleted_UpdatesEventStatus()
{
// Arrange — create a search event first
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(updatedEvent);
Assert.Equal(SearchCommandStatus.Completed, updatedEvent.SearchStatus);
}
[Fact]
public async Task PublishSearchCompleted_SetsCompletedAt()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(updatedEvent);
Assert.NotNull(updatedEvent.CompletedAt);
}
[Fact]
public async Task PublishSearchCompleted_MergesResultDataIntoExistingData()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
var resultData = new { GrabbedItems = new[] { new { Title = "Movie A (2024)", Status = "downloading" } } };
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(updatedEvent);
Assert.NotNull(updatedEvent.Data);
// Original data should still be present
Assert.Contains("Movie A", updatedEvent.Data);
// Merged result data should be present
Assert.Contains("GrabbedItems", updatedEvent.Data);
Assert.Contains("Movie A (2024)", updatedEvent.Data);
}
[Fact]
public async Task PublishSearchCompleted_WithNullResultData_DoesNotModifyData()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
var originalEvent = await _context.Events.FindAsync(eventId);
string? originalData = originalEvent!.Data;
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
Assert.NotNull(updatedEvent);
Assert.Equal(originalData, updatedEvent.Data);
}
[Fact]
public async Task PublishSearchCompleted_EventNotFound_LogsWarningAndReturns()
{
// Act — use a non-existent event ID
await _publisher.PublishSearchCompleted(Guid.NewGuid(), SearchCommandStatus.Completed);
// Assert — should not throw, and the log warning is the important behavior
// (no exception thrown is the assertion)
var eventCount = await _context.Events.CountAsync();
Assert.Equal(0, eventCount);
}
[Fact]
public async Task PublishSearchCompleted_NotifiesSignalRClients()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
// Reset mock to only capture the completion call
_clientProxyMock.Invocations.Clear();
// Act
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
// Assert
_clientProxyMock.Verify(c => c.SendCoreAsync(
"EventReceived",
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
}
File diff suppressed because it is too large. Load diff
@@ -149,21 +149,17 @@ public class TransmissionItemWrapperTests
}
[Theory]
[InlineData(1024L, 512L, 2.0)] // Uploaded more than downloaded
[InlineData(512L, 1024L, 0.5)] // Uploaded less than downloaded
[InlineData(1024L, 1024L, 1.0)] // Equal
[InlineData(0L, 1024L, 0.0)] // No upload
[InlineData(1024L, 0L, 0.0)] // No download
[InlineData(null, 1024L, 0.0)] // Null upload
[InlineData(1024L, null, 0.0)] // Null download
[InlineData(null, null, 0.0)] // Both null
public void Ratio_ReturnsCorrectValue(long? uploadedEver, long? downloadedEver, double expected)
[InlineData(2.0, 2.0)]
[InlineData(0.5, 0.5)]
[InlineData(1.0, 1.0)]
[InlineData(0.0, 0.0)]
[InlineData(null, 0.0)]
public void Ratio_ReturnsCorrectValue(double? uploadRatio, double expected)
{
// Arrange
var torrentInfo = new TorrentInfo
{
UploadedEver = uploadedEver,
DownloadedEver = downloadedEver
uploadRatio = uploadRatio
};
var wrapper = new TransmissionItemWrapper(torrentInfo);
@@ -1,166 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers;
public class DownloadHunterConsumerTests
{
private readonly Mock<ILogger<DownloadHunterConsumer<SearchItem>>> _loggerMock;
private readonly Mock<IDownloadHunter> _downloadHunterMock;
private readonly DownloadHunterConsumer<SearchItem> _consumer;
public DownloadHunterConsumerTests()
{
_loggerMock = new Mock<ILogger<DownloadHunterConsumer<SearchItem>>>();
_downloadHunterMock = new Mock<IDownloadHunter>();
_consumer = new DownloadHunterConsumer<SearchItem>(_loggerMock.Object, _downloadHunterMock.Object);
}
#region Consume Tests
[Fact]
public async Task Consume_CallsHuntDownloadsAsync()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once);
}
[Fact]
public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.ThrowsAsync(new Exception("Hunt failed"));
// Act - Should not throw
await _consumer.Consume(contextMock.Object);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to search for replacement")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Consume_PassesCorrectRequestToHunter()
{
// Arrange
var request = CreateHuntRequest();
var contextMock = CreateConsumeContextMock(request);
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Callback<DownloadHuntRequest<SearchItem>>(r => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
}
[Fact]
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
{
// Arrange
var request = new DownloadHuntRequest<SearchItem>
{
InstanceType = InstanceType.Lidarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 999 },
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
var contextMock = CreateConsumeContextMock(request);
_downloadHunterMock
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(contextMock.Object);
// Assert
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(
It.Is<DownloadHuntRequest<SearchItem>>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once);
}
#endregion
#region Helper Methods
private static DownloadHuntRequest<SearchItem> CreateHuntRequest()
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = InstanceType.Radarr,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://radarr.local"),
ApiKey = "test-api-key"
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
{
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
mock.Setup(c => c.Message).Returns(message);
return mock;
}
#endregion
}
@@ -1,313 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
public class DownloadHunterTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly FakeTimeProvider _fakeTimeProvider;
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
private readonly SqliteConnection _connection;
public DownloadHunterTests()
{
// Use SQLite in-memory with shared connection to support complex types
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(_connection)
.Options;
_dataContext = new DataContext(options);
_dataContext.Database.EnsureCreated();
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_fakeTimeProvider = new FakeTimeProvider();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
_dataContext,
_arrClientFactoryMock.Object,
_fakeTimeProvider
);
}
public void Dispose()
{
_dataContext.Dispose();
_connection.Dispose();
}
#region HuntDownloadsAsync - Search Disabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
await _downloadHunter.HuntDownloadsAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()), Times.Never);
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
{
// Arrange
await SetupGeneralConfig(searchEnabled: false);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should complete without needing to advance time
var completedTask = await Task.WhenAny(task, Task.Delay(100));
Assert.Same(task, completedTask);
}
#endregion
#region HuntDownloadsAsync - Search Enabled Tests
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act - Start the task and advance time
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType, It.IsAny<float>()), Times.Once);
}
[Fact]
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientMock.Verify(
c => c.SearchItemsAsync(
request.Instance,
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
Times.Once);
}
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest(instanceType);
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
}
#endregion
#region HuntDownloadsAsync - Delay Tests
[Fact]
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
{
// Arrange
const ushort configuredDelay = 120;
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Task should not complete before advancing time
Assert.False(task.IsCompleted);
// Advance partial time - should still not complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
await Task.Delay(10); // Give the task a chance to complete if it would
Assert.False(task.IsCompleted);
// Advance remaining time - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
{
// Arrange - Set delay below minimum (simulating manual DB edit)
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by the below-min value - should NOT complete because it should use default
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance to default delay - should now complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
{
// Arrange
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Assert - Should not complete immediately
Assert.False(task.IsCompleted);
// Advance to default delay
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay exactly at minimum
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should complete
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
[Fact]
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
{
// Arrange - Set delay above minimum
const ushort aboveMinDelay = 180;
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
var request = CreateHuntRequest();
// Act
var task = _downloadHunter.HuntDownloadsAsync(request);
// Advance by minimum - should NOT complete yet
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
await Task.Delay(10);
Assert.False(task.IsCompleted);
// Advance remaining time
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
await task;
Assert.True(task.IsCompletedSuccessfully);
}
#endregion
#region Helper Methods
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
{
var generalConfig = new GeneralConfig
{
SearchEnabled = searchEnabled,
SearchDelay = searchDelay
};
_dataContext.GeneralConfigs.Add(generalConfig);
await _dataContext.SaveChangesAsync();
}
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
{
return new DownloadHuntRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
JobRunId = Guid.NewGuid()
};
}
private static ArrInstance CreateArrInstance()
{
return new ArrInstance
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key",
Version = 0
};
}
private static QueueRecord CreateQueueRecord()
{
return new QueueRecord
{
Id = 1,
Title = "Test Record",
Protocol = "torrent",
DownloadId = "ABC123"
};
}
#endregion
}
@@ -3,7 +3,7 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
@@ -14,8 +14,8 @@ using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -27,18 +27,18 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
public class QueueItemRemoverTests : IDisposable
{
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
private readonly Mock<IBus> _busMock;
private readonly MemoryCache _memoryCache;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _arrClientMock;
private readonly EventPublisher _eventPublisher;
private readonly EventsContext _eventsContext;
private readonly DataContext _dataContext;
private readonly QueueItemRemover _queueItemRemover;
private readonly Guid _jobRunId;
public QueueItemRemoverTests()
{
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
_busMock = new Mock<IBus>();
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
@@ -50,13 +50,20 @@ public class QueueItemRemoverTests : IDisposable
// Create real EventPublisher with mocked dependencies
_eventsContext = TestEventsContextFactory.Create();
// Create a JobRun so event FK constraints are satisfied when events are saved
_jobRunId = Guid.NewGuid();
_eventsContext.JobRuns.Add(new Persistence.Models.State.JobRun { Id = _jobRunId, Type = JobType.QueueCleaner });
_eventsContext.SaveChanges();
ContextProvider.SetJobRunId(_jobRunId);
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// Setup interceptor to skip actual database saves (these tests verify QueueItemRemover, not EventPublisher)
dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
// Setup interceptor for other uses (e.g., ArrClient deletion)
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns(Task.CompletedTask);
@@ -68,13 +75,16 @@ public class QueueItemRemoverTests : IDisposable
Mock.Of<INotificationPublisher>(),
dryRunInterceptorMock.Object);
// Create in-memory DataContext with seeded SeekerConfig
_dataContext = TestDataContextFactory.Create();
_queueItemRemover = new QueueItemRemover(
_loggerMock.Object,
_busMock.Object,
_memoryCache,
_arrClientFactoryMock.Object,
_eventPublisher,
_eventsContext
_eventsContext,
_dataContext
);
// Clear static RecurringHashes before each test
@@ -85,6 +95,7 @@ public class QueueItemRemoverTests : IDisposable
{
_memoryCache.Dispose();
_eventsContext.Dispose();
_dataContext.Dispose();
Striker.RecurringHashes.Clear();
}
@@ -116,11 +127,10 @@ public class QueueItemRemoverTests : IDisposable
}
[Fact]
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
public async Task RemoveQueueItemAsync_Success_AddsSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
DownloadHuntRequest<SearchItem>? capturedRequest = null;
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
@@ -130,23 +140,15 @@ public class QueueItemRemoverTests : IDisposable
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
_busMock
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedRequest);
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
Assert.Equal(request.Instance, capturedRequest.Instance);
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
Assert.Single(queueItems);
Assert.Equal(request.Instance.Id, queueItems[0].ArrInstanceId);
Assert.Equal(request.SearchItem.Id, queueItems[0].ItemId);
Assert.Equal(request.Record.Title, queueItems[0].Title);
}
[Fact]
@@ -203,7 +205,7 @@ public class QueueItemRemoverTests : IDisposable
#region RemoveQueueItemAsync - Recurring Hash Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotAddSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
@@ -222,9 +224,8 @@ public class QueueItemRemoverTests : IDisposable
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
Assert.Empty(queueItems);
}
[Fact]
@@ -251,7 +252,7 @@ public class QueueItemRemoverTests : IDisposable
}
[Fact]
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_AddsSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest();
@@ -268,9 +269,8 @@ public class QueueItemRemoverTests : IDisposable
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Once);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
Assert.Single(queueItems);
}
#endregion
@@ -278,7 +278,7 @@ public class QueueItemRemoverTests : IDisposable
#region RemoveQueueItemAsync - SkipSearch Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest()
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotAddSearchQueueItem()
{
// Arrange
var request = CreateRemoveRequest(skipSearch: true);
@@ -295,9 +295,8 @@ public class QueueItemRemoverTests : IDisposable
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_busMock.Verify(b => b.Publish(
It.IsAny<DownloadHuntRequest<SearchItem>>(),
It.IsAny<CancellationToken>()), Times.Never);
var queueItems = await _dataContext.SearchQueue.ToListAsync();
Assert.Empty(queueItems);
}
[Fact]
@@ -324,6 +323,36 @@ public class QueueItemRemoverTests : IDisposable
#endregion
#region RemoveQueueItemAsync - SearchEnabled Tests
[Fact]
public async Task RemoveQueueItemAsync_WhenSearchDisabled_DoesNotAddSearchQueueItem()
{
// Arrange
var seekerConfig = await _dataContext.SeekerConfigs.FirstAsync();
seekerConfig.SearchEnabled = false;
await _dataContext.SaveChangesAsync();
var request = CreateRemoveRequest();
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
var queueItems = await _dataContext.SearchQueue.ToListAsync();
Assert.Empty(queueItems);
}
#endregion
#region RemoveQueueItemAsync - HTTP Error Tests
[Fact]
@@ -481,32 +510,38 @@ public class QueueItemRemoverTests : IDisposable
#region Helper Methods
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
private QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
InstanceType instanceType = InstanceType.Sonarr,
bool removeFromClient = true,
DeleteReason deleteReason = DeleteReason.Stalled,
bool skipSearch = false)
{
// Use an ArrInstance that exists in the DB to satisfy FK constraint on SearchQueueItem
var instance = GetOrCreateArrInstance(instanceType);
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = instanceType,
Instance = CreateArrInstance(),
Instance = instance,
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason,
SkipSearch = skipSearch,
JobRunId = Guid.NewGuid()
JobRunId = _jobRunId
};
}
private static ArrInstance CreateArrInstance()
private ArrInstance GetOrCreateArrInstance(InstanceType instanceType)
{
return new ArrInstance
return instanceType switch
{
Name = "Test Instance",
Url = new Uri("http://arr.local"),
ApiKey = "test-api-key"
InstanceType.Sonarr => TestDataContextFactory.AddSonarrInstance(_dataContext),
InstanceType.Radarr => TestDataContextFactory.AddRadarrInstance(_dataContext),
InstanceType.Lidarr => TestDataContextFactory.AddLidarrInstance(_dataContext),
InstanceType.Readarr => TestDataContextFactory.AddReadarrInstance(_dataContext),
InstanceType.Whisparr => TestDataContextFactory.AddWhisparrInstance(_dataContext),
_ => TestDataContextFactory.AddSonarrInstance(_dataContext),
};
}
@@ -0,0 +1,868 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using CustomFormatScoreSyncer = Cleanuparr.Infrastructure.Features.Jobs.CustomFormatScoreSyncer;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class CustomFormatScoreSyncerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<CustomFormatScoreSyncer>> _logger;
private readonly Mock<IRadarrClient> _radarrClient;
private readonly Mock<ISonarrClient> _sonarrClient;
private readonly Mock<IHubContext<AppHub>> _hubContext;
public CustomFormatScoreSyncerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = new Mock<ILogger<CustomFormatScoreSyncer>>();
_radarrClient = new Mock<IRadarrClient>();
_sonarrClient = new Mock<ISonarrClient>();
_hubContext = new Mock<IHubContext<AppHub>>();
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
_hubContext.Setup(h => h.Clients).Returns(mockClients.Object);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private CustomFormatScoreSyncer CreateSut()
{
return new CustomFormatScoreSyncer(
_logger.Object,
_fixture.DataContext,
_radarrClient.Object,
_sonarrClient.Object,
_fixture.TimeProvider,
_hubContext.Object
);
}
#region ExecuteAsync Tests
[Fact]
public async Task ExecuteAsync_WhenCustomFormatScoreDisabled_ReturnsEarly()
{
// Arrange — UseCustomFormatScore is false by default in seed data
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = false;
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no API calls made
_radarrClient.Verify(
x => x.GetAllMoviesAsync(It.IsAny<ArrInstance>()),
Times.Never);
_sonarrClient.Verify(
x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WhenNoEnabledInstances_ReturnsEarly()
{
// Arrange — enable CF scoring but add no SeekerInstanceConfigs
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no API calls
_radarrClient.Verify(
x => x.GetAllMoviesAsync(It.IsAny<ArrInstance>()),
Times.Never);
_sonarrClient.Verify(
x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_SyncsRadarrMovieScores()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
// Mock quality profiles
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Mock movies with files
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10,
Title = "Test Movie",
HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1,
Status = "released",
Monitored = true
}
]);
// Mock file scores
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — CF score entry was saved
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
var entry = entries[0];
Assert.Equal(radarrInstance.Id, entry.ArrInstanceId);
Assert.Equal(10, entry.ExternalItemId);
Assert.Equal(250, entry.CurrentScore);
Assert.Equal(500, entry.CutoffScore);
Assert.Equal("HD", entry.QualityProfileName);
Assert.Equal(InstanceType.Radarr, entry.ItemType);
Assert.True(entry.IsMonitored);
// Initial history entry should also be created
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(250, history[0].Score);
}
[Fact]
public async Task ExecuteAsync_RecordsHistoryOnScoreChange()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing CF score entry with a different score
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Test Movie",
FileId = 100,
CurrentScore = 200,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
// Mock quality profiles
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Mock movies — same movie but score changed from 200 to 350
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10,
Title = "Test Movie",
HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1,
Status = "released",
Monitored = true
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 350 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — existing entry should be updated
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(350, entries[0].CurrentScore);
// History entry should be created because score changed (200 -> 350)
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(350, history[0].Score);
Assert.Equal(InstanceType.Radarr, history[0].ItemType);
}
[Fact]
public async Task ExecuteAsync_TracksUnmonitoredMovie()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10,
Title = "Unmonitored Movie",
HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1,
Status = "released",
Monitored = false
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry should be saved with IsMonitored = false
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.False(entries[0].IsMonitored);
}
[Fact]
public async Task ExecuteAsync_UpdatesMonitoredStatusOnSync()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry that was monitored
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Test Movie",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
IsMonitored = true,
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Movie is now unmonitored
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Test Movie", HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = false
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — IsMonitored should be updated to false
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.False(entries[0].IsMonitored);
}
#endregion
#region Sonarr Sync Tests
[Fact]
public async Task ExecuteAsync_SyncsSonarrEpisodeScores()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
// Mock quality profiles
_sonarrClient
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Mock series
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync([
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]);
// Mock episodes — one with a file, one without
_sonarrClient
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
.ReturnsAsync([
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true },
new SearchableEpisode { Id = 101, SeasonNumber = 1, EpisodeNumber = 2, EpisodeFileId = 0, HasFile = false }
]);
// Mock episode files with CF scores
_sonarrClient
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
.ReturnsAsync([
new ArrEpisodeFile { Id = 500, CustomFormatScore = 300, QualityCutoffNotMet = false }
]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — only the episode with a file should have an entry
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
var entry = entries[0];
Assert.Equal(sonarrInstance.Id, entry.ArrInstanceId);
Assert.Equal(10, entry.ExternalItemId);
Assert.Equal(100, entry.EpisodeId);
Assert.Equal(300, entry.CurrentScore);
Assert.Equal(500, entry.CutoffScore);
Assert.Equal(InstanceType.Sonarr, entry.ItemType);
Assert.True(entry.IsMonitored);
Assert.Contains("S01E01", entry.Title);
// Initial history should be created
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(300, history[0].Score);
}
[Fact]
public async Task ExecuteAsync_SonarrSync_SkipsEpisodesWithoutFiles()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
_sonarrClient
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync([
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]);
// All episodes have EpisodeFileId = 0 (no file)
_sonarrClient
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
.ReturnsAsync([
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false }
]);
_sonarrClient
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
.ReturnsAsync([]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no entries created
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Empty(entries);
}
#endregion
#region Score Unchanged Tests
[Fact]
public async Task ExecuteAsync_ScoreUnchanged_DoesNotRecordHistory()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry with score = 250 (same as what will be returned)
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Test Movie",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Test Movie", HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
// Score unchanged: still 250
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no history entries (score didn't change)
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Empty(history);
// Entry should still be updated (LastSyncedAt changes)
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(250, entries[0].CurrentScore);
}
#endregion
#region Stale Entry Cleanup Tests
[Fact]
public async Task ExecuteAsync_CleansUpEntriesForRemovedMovies()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry for a movie that no longer exists in library
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 999,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Deleted Movie",
FileId = 999,
CurrentScore = 100,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Library now only has movie 10 (not 999)
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Current Movie", HasFile = true,
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry for removed movie 999 should be deleted
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
}
[Fact]
public async Task ExecuteAsync_PreservesEntryWhenMovieExistsButHasNoFile()
{
// Arrange — simulates an RSS upgrade where the old file was removed
// but the new file hasn't been imported yet
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry with score history
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
Score = 250,
CutoffScore = 500,
RecordedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Movie still exists in Radarr but HasFile is false (RSS upgrade in progress)
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Mario Bros", HasFile = false,
MovieFile = null,
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry and history should be preserved since the movie still exists
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
Assert.Equal(250, entries[0].CurrentScore);
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
Assert.Equal(250, history[0].Score);
}
[Fact]
public async Task ExecuteAsync_PreservesEntryWhenMovieFileScoreNotReturned()
{
// Arrange — simulates a newly imported file that doesn't have CF scores calculated yet
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
// Pre-existing entry with history
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
FileId = 100,
CurrentScore = 250,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 0,
ItemType = InstanceType.Radarr,
Title = "Mario Bros",
Score = 250,
CutoffScore = 500,
RecordedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_radarrClient
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
// Movie has a new file (different FileId) after RSS upgrade
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync([
new SearchableMovie
{
Id = 10, Title = "Mario Bros", HasFile = true,
MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false },
QualityProfileId = 1, Status = "released", Monitored = true
}
]);
// New file returns no score (not yet calculated by Radarr)
_radarrClient
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
.ReturnsAsync(new Dictionary<long, int>());
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry and history should be preserved since the movie still exists
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
Assert.Equal(250, entries[0].CurrentScore);
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
}
[Fact]
public async Task ExecuteAsync_Sonarr_PreservesEntryWhenEpisodeTemporarilyWithoutFile()
{
// Arrange — simulates a Sonarr episode whose file was replaced via RSS
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
// Pre-existing CF score entry for an episode
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
Title = "Test Series S01E01",
FileId = 500,
CurrentScore = 300,
CutoffScore = 500,
QualityProfileName = "HD",
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
Title = "Test Series S01E01",
Score = 300,
CutoffScore = 500,
RecordedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
_sonarrClient
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync([
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]);
// Episode exists but has no file currently (RSS upgrade in progress)
_sonarrClient
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
.ReturnsAsync([
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false, Monitored = true }
]);
_sonarrClient
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
.ReturnsAsync([]);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — entry and history should be preserved
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
Assert.Single(entries);
Assert.Equal(10, entries[0].ExternalItemId);
Assert.Equal(100, entries[0].EpisodeId);
Assert.Equal(300, entries[0].CurrentScore);
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
Assert.Single(history);
}
#endregion
}
File diff suppressed because it is too large. Load diff
@@ -6,6 +6,7 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -94,6 +95,14 @@ public static class TestDataContextFactory
UnlinkedCategories = []
});
// Seeker config
context.SeekerConfigs.Add(new SeekerConfig
{
Id = Guid.NewGuid(),
SearchEnabled = true,
ProactiveSearchEnabled = false
});
context.SaveChanges();
}
@@ -0,0 +1,420 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Infrastructure.Features.Seeker.Selectors;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Seeker;
public sealed class ItemSelectorTests
{
private static readonly List<(long Id, DateTime? Added, DateTime? LastSearched)> SampleCandidates =
[
(1, new DateTime(2024, 1, 1), new DateTime(2024, 6, 1)),
(2, new DateTime(2024, 3, 1), new DateTime(2024, 5, 1)),
(3, new DateTime(2024, 5, 1), null),
(4, new DateTime(2024, 2, 1), new DateTime(2024, 7, 1)),
(5, null, new DateTime(2024, 4, 1)),
];
#region ItemSelectorFactory Tests
[Theory]
[InlineData(SelectionStrategy.OldestSearchFirst, typeof(OldestSearchFirstSelector))]
[InlineData(SelectionStrategy.OldestSearchWeighted, typeof(OldestSearchWeightedSelector))]
[InlineData(SelectionStrategy.NewestFirst, typeof(NewestFirstSelector))]
[InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))]
[InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))]
[InlineData(SelectionStrategy.Random, typeof(RandomSelector))]
public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType)
{
var selector = ItemSelectorFactory.Create(strategy);
Assert.IsType(expectedType, selector);
}
[Fact]
public void Factory_Create_InvalidStrategy_ThrowsArgumentOutOfRangeException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => ItemSelectorFactory.Create((SelectionStrategy)999));
}
#endregion
#region NewestFirstSelector Tests
[Fact]
public void NewestFirst_Select_OrdersByAddedDescending()
{
var selector = new NewestFirstSelector();
var result = selector.Select(SampleCandidates, 3);
// Newest added: 3 (May), 2 (Mar), 4 (Feb)
Assert.Equal([3, 2, 4], result);
}
[Fact]
public void NewestFirst_Select_NullAddedDates_TreatedAsOldest()
{
var selector = new NewestFirstSelector();
// Select all — item 5 (null Added) should be last
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(5, result.Last());
}
[Fact]
public void NewestFirst_Select_ReturnsRequestedCount()
{
var selector = new NewestFirstSelector();
var result = selector.Select(SampleCandidates, 2);
Assert.Equal(2, result.Count);
}
[Fact]
public void NewestFirst_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new NewestFirstSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void NewestFirst_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new NewestFirstSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
#endregion
#region OldestSearchFirstSelector Tests
[Fact]
public void OldestSearchFirst_Select_OrdersByLastSearchedAscending()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select(SampleCandidates, 3);
// Never searched first (null → MinValue), then oldest: 3 (null), 5 (Apr), 2 (May)
Assert.Equal([3, 5, 2], result);
}
[Fact]
public void OldestSearchFirst_Select_NullLastSearched_PrioritizedFirst()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select(SampleCandidates, 1);
// Item 3 has LastSearched = null, should be first
Assert.Equal(3, result[0]);
}
[Fact]
public void OldestSearchFirst_Select_ReturnsRequestedCount()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select(SampleCandidates, 2);
Assert.Equal(2, result.Count);
}
[Fact]
public void OldestSearchFirst_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new OldestSearchFirstSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
#endregion
#region RandomSelector Tests
[Fact]
public void Random_Select_ReturnsRequestedCount()
{
var selector = new RandomSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void Random_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new RandomSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void Random_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new RandomSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void Random_Select_NoDuplicateIds()
{
var selector = new RandomSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void Random_Select_ResultsAreSubsetOfInput()
{
var selector = new RandomSelector();
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
var result = selector.Select(SampleCandidates, 3);
Assert.All(result, id => Assert.Contains(id, inputIds));
}
#endregion
#region NewestWeightedSelector Tests
[Fact]
public void NewestWeighted_Select_ReturnsRequestedCount()
{
var selector = new NewestWeightedSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void NewestWeighted_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new NewestWeightedSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void NewestWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new NewestWeightedSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void NewestWeighted_Select_NoDuplicateIds()
{
var selector = new NewestWeightedSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new NewestWeightedSelector();
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
var result = selector.Select(single, 1);
Assert.Single(result);
Assert.Equal(42, result[0]);
}
#endregion
#region OldestSearchWeightedSelector Tests
[Fact]
public void OldestSearchWeighted_Select_ReturnsRequestedCount()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void OldestSearchWeighted_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void OldestSearchWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void OldestSearchWeighted_Select_NoDuplicateIds()
{
var selector = new OldestSearchWeightedSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new OldestSearchWeightedSelector();
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
var result = selector.Select(single, 1);
Assert.Single(result);
Assert.Equal(42, result[0]);
}
#endregion
#region BalancedWeightedSelector Tests
[Fact]
public void BalancedWeighted_Select_ReturnsRequestedCount()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select(SampleCandidates, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void BalancedWeighted_Select_EmptyInput_ReturnsEmptyList()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select([], 5);
Assert.Empty(result);
}
[Fact]
public void BalancedWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select(SampleCandidates, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void BalancedWeighted_Select_NoDuplicateIds()
{
var selector = new BalancedWeightedSelector();
var result = selector.Select(SampleCandidates, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new BalancedWeightedSelector();
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
var result = selector.Select(single, 1);
Assert.Single(result);
Assert.Equal(42, result[0]);
}
[Fact]
public void BalancedWeighted_Select_ResultsAreSubsetOfInput()
{
var selector = new BalancedWeightedSelector();
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
var result = selector.Select(SampleCandidates, 3);
Assert.All(result, id => Assert.Contains(id, inputIds));
}
#endregion
#region WeightedRandomByRank Tests
[Fact]
public void WeightedRandomByRank_ReturnsRequestedCount()
{
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 3);
Assert.Equal(3, result.Count);
}
[Fact]
public void WeightedRandomByRank_CountExceedsCandidates_ReturnsAll()
{
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 100);
Assert.Equal(5, result.Count);
}
[Fact]
public void WeightedRandomByRank_NoDuplicateIds()
{
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 5);
Assert.Equal(result.Count, result.Distinct().Count());
}
[Fact]
public void WeightedRandomByRank_EmptyInput_ReturnsEmptyList()
{
var result = OldestSearchWeightedSelector.WeightedRandomByRank([], 5);
Assert.Empty(result);
}
#endregion
}
@@ -40,10 +40,8 @@ public class StrikerTests : IDisposable
var notificationPublisher = Substitute.For<INotificationPublisher>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests)
dryRunInterceptor
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(Task.CompletedTask);
// Configure dry run interceptor to report dry run as disabled by default
dryRunInterceptor.IsDryRunEnabled().Returns(false);
_eventPublisher = new EventPublisher(
eventsContext,
@@ -52,7 +50,7 @@ public class StrikerTests : IDisposable
notificationPublisher,
dryRunInterceptor);
_striker = new Striker(_logger, _strikerContext, _eventPublisher);
_striker = new Striker(_logger, _strikerContext, _eventPublisher, dryRunInterceptor);
// Clear static state before each test
Striker.RecurringHashes.Clear();
@@ -15,6 +15,7 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.7.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
@@ -43,7 +43,7 @@ public class EventPublisher : IEventPublisher
/// <summary>
/// Generic method for publishing events to database and SignalR clients
/// </summary>
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null)
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null)
{
AppEvent eventEntity = new()
{
@@ -63,16 +63,15 @@ public class EventPublisher : IEventPublisher
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
// Save to database with dry run interception
await _dryRunInterceptor.InterceptAsync(SaveEventToDatabase, eventEntity);
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
await SaveEventToDatabase(eventEntity);
// Always send to SignalR clients (not affected by dry run)
await NotifyClientsAsync(eventEntity);
_logger.LogTrace("Published event: {eventType}", eventType);
}
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null)
{
ManualEvent eventEntity = new()
{
@@ -89,10 +88,9 @@ public class EventPublisher : IEventPublisher
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
// Save to database with dry run interception
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
await SaveManualEventToDatabase(eventEntity);
// Always send to SignalR clients (not affected by dry run)
await NotifyClientsAsync(eventEntity);
_logger.LogTrace("Published manual event: {message}", message);
@@ -139,16 +137,19 @@ public class EventPublisher : IEventPublisher
};
}
bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled();
// Publish the event
await PublishAsync(
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
data: data,
strikeId: strikeId);
strikeId: strikeId,
isDryRun: isDryRun);
// Broadcast strike to SignalR clients for real-time dashboard updates
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName);
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName, isDryRun);
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
@@ -226,6 +227,86 @@ public class EventPublisher : IEventPublisher
);
}
/// <summary>
/// Publishes a search triggered event with context data and notifications.
/// Returns the event ID so the SeekerCommandMonitor can update it on completion.
/// </summary>
public async Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null)
{
var itemList = items as string[] ?? items.ToArray();
var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : "");
AppEvent eventEntity = new()
{
EventType = EventType.SearchTriggered,
Message = $"Searched {itemCount} items on {instanceName}: {itemsDisplay}",
Severity = EventSeverity.Information,
Data = JsonSerializer.Serialize(
new { InstanceName = instanceName, ItemCount = itemCount, Items = itemList, SearchType = searchType.ToString(), CycleId = cycleId },
new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }),
SearchStatus = SearchCommandStatus.Pending,
JobRunId = ContextProvider.TryGetJobRunId(),
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
CycleId = cycleId,
};
eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled();
await SaveEventToDatabase(eventEntity);
await NotifyClientsAsync(eventEntity);
await _notificationPublisher.NotifySearchTriggered(instanceName, itemCount, itemList);
return eventEntity.Id;
}
/// <summary>
/// Updates an existing search event with completion status and optional result data
/// </summary>
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null)
{
var existingEvent = await _context.Events.FindAsync(eventId);
if (existingEvent is null)
{
_logger.LogWarning("Could not find search event {EventId} to update completion status", eventId);
return;
}
existingEvent.SearchStatus = status;
existingEvent.CompletedAt = DateTime.UtcNow;
if (resultData is not null)
{
// Merge result data into existing Data JSON
var existingData = existingEvent.Data is not null
? JsonSerializer.Deserialize<Dictionary<string, object>>(existingEvent.Data)
: new Dictionary<string, object>();
var resultJson = JsonSerializer.Serialize(resultData, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
});
var resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(resultJson);
if (existingData is not null && resultDict is not null)
{
foreach (var kvp in resultDict)
{
existingData[kvp.Key] = kvp.Value;
}
existingEvent.Data = JsonSerializer.Serialize(existingData, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
});
}
}
await _context.SaveChangesAsync();
await NotifyClientsAsync(existingEvent);
}
/// <summary>
/// Publishes an event alerting that search was not triggered for an item
/// </summary>
@@ -276,7 +357,7 @@ public class EventPublisher : IEventPublisher
}
}
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName)
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName, bool isDryRun)
{
try
{
@@ -287,6 +368,7 @@ public class EventPublisher : IEventPublisher
CreatedAt = DateTime.UtcNow,
DownloadId = hash,
Title = itemName,
IsDryRun = isDryRun,
};
await _appHubContext.Clients.All.SendAsync("StrikeReceived", strike);
}
@@ -4,9 +4,9 @@ namespace Cleanuparr.Infrastructure.Events.Interfaces;
public interface IEventPublisher
{
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null);
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null);
@@ -19,4 +19,8 @@ public interface IEventPublisher
Task PublishRecurringItem(string hash, string itemName, int strikeCount);
Task PublishSearchNotTriggered(string hash, string itemName);
Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null);
Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null);
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -41,8 +42,8 @@ public abstract class ArrClient : IArrClient
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
try
{
@@ -53,18 +54,46 @@ public abstract class ArrClient : IArrClient
_logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
throw;
}
string responseBody = await response.Content.ReadAsStringAsync();
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
QueueListResponse? queueResponse = await DeserializeStreamAsync<QueueListResponse>(response);
if (queueResponse is null)
{
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri}");
}
return queueResponse;
}
public async Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance)
{
int count = 0;
int page = 1;
int processed = 0;
while (true)
{
QueueListResponse response = await GetQueueItemsAsync(arrInstance, page);
if (response.Records.Count == 0)
{
break;
}
count += response.Records.Count(r => r.SizeLeft > 0);
processed += response.Records.Count;
if (processed >= response.TotalRecords)
{
break;
}
page++;
}
return count;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
@@ -76,20 +105,28 @@ public abstract class ArrClient : IArrClient
return false;
}
bool hasWarn() => record.TrackedDownloadStatus
bool HasWarn() => record.TrackedDownloadStatus
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
bool isImportBlocked() => record.TrackedDownloadState
bool IsImportBlocked() => record.TrackedDownloadState
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
bool isImportPending() => record.TrackedDownloadState
bool IsImportPending() => record.TrackedDownloadState
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
bool isImportFailed() => record.TrackedDownloadState
bool IsImportFailed() => record.TrackedDownloadState
.Equals("importFailed", StringComparison.InvariantCultureIgnoreCase);
bool isFailedLidarr() => instanceType is InstanceType.Lidarr &&
bool IsFailedLidarr() => instanceType is InstanceType.Lidarr &&
(record.Status.Equals("failed", StringComparison.InvariantCultureIgnoreCase) ||
record.Status.Equals("completed", StringComparison.InvariantCultureIgnoreCase)) &&
hasWarn();
HasWarn();
bool IsDownloading() => record.TrackedDownloadState
.Equals("downloading", StringComparison.InvariantCultureIgnoreCase);
bool HasFailedImportMessage() => record.StatusMessages
?.Any(status => status.Messages
?.Any(message => message.StartsWith("Unable to import automatically", StringComparison.InvariantCultureIgnoreCase)) is true
) is true;
bool IsEdgeCase() => IsDownloading() && HasFailedImportMessage();
if (hasWarn() && (isImportBlocked() || isImportPending() || isImportFailed()) || isFailedLidarr())
if (HasWarn() && (IsImportBlocked() || IsImportPending() || IsImportFailed()) || IsFailedLidarr() || IsEdgeCase())
{
if (!ShouldStrikeFailedImport(queueCleanerConfig, record))
{
@@ -117,6 +154,8 @@ public abstract class ArrClient : IArrClient
StrikeType.FailedImport
);
}
_logger.LogDebug("skip | not a failed import | {name}", record.Title);
return false;
}
@@ -156,7 +195,7 @@ public abstract class ArrClient : IArrClient
}
}
public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
public abstract Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
public bool IsRecordValid(QueueRecord record)
{
@@ -187,6 +226,23 @@ public abstract class ArrClient : IArrClient
_logger.LogDebug("Connection test successful for {url}", arrInstance.Url);
}
/// <inheritdoc/>
public async Task<ArrCommandStatus> GetCommandStatusAsync(ArrInstance arrInstance, long commandId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command/{commandId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var result = await DeserializeStreamAsync<ArrCommandStatus>(response);
return result ?? new ArrCommandStatus(commandId, "unknown", null);
}
protected abstract string GetSystemStatusUrlPath();
protected abstract string GetQueueUrlPath();
@@ -211,6 +267,26 @@ public abstract class ArrClient : IArrClient
return response;
}
protected static async Task<T?> DeserializeStreamAsync<T>(HttpResponseMessage response)
{
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
return JsonSerializer.CreateDefault().Deserialize<T>(reader);
}
protected static async Task<long?> ReadCommandIdAsync(HttpResponseMessage response)
{
CommandIdResponse? result = await DeserializeStreamAsync<CommandIdResponse>(response);
return result?.Id;
}
private sealed class CommandIdResponse
{
[JsonProperty("id")]
public long? Id { get; init; }
}
/// <summary>
/// Determines whether the failed import record should be skipped
/// </summary>
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
@@ -12,9 +13,17 @@ public interface IArrClient
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
/// <summary>
/// Triggers a search for the specified items and returns the arr command IDs
/// </summary>
Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
/// <summary>
/// Gets the status of an arr command by its ID
/// </summary>
Task<ArrCommandStatus> GetCommandStatusAsync(ArrInstance arrInstance, long commandId);
bool IsRecordValid(QueueRecord record);
/// <summary>
@@ -30,4 +39,10 @@ public interface IArrClient
/// <param name="arrInstance">The instance to test connection to</param>
/// <returns>Task that completes when the connection test is done</returns>
Task HealthCheckAsync(ArrInstance arrInstance);
/// <summary>
/// Returns the number of items actively downloading (SizeLeft > 0) across all queue pages.
/// Items that are completed, import-blocked, or otherwise finished are not counted.
/// </summary>
Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance);
}
@@ -1,5 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IRadarrClient : IArrClient
{
/// <summary>
/// Fetches all movies from a Radarr instance
/// </summary>
Task<List<SearchableMovie>> GetAllMoviesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches quality profiles from a Radarr instance
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches custom format scores for movie files in batches
/// </summary>
Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds);
}
@@ -1,5 +1,32 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface ISonarrClient : IArrClient
{
/// <summary>
/// Fetches all series from a Sonarr instance
/// </summary>
Task<List<SearchableSeries>> GetAllSeriesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches all episodes for a specific series from a Sonarr instance
/// </summary>
Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId);
/// <summary>
/// Fetches quality profiles from a Sonarr instance
/// </summary>
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
/// <summary>
/// Fetches episode file metadata for a specific series, including quality cutoff status
/// </summary>
Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId);
/// <summary>
/// Fetches custom format scores for episode files in batches
/// </summary>
Task<Dictionary<long, int>> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List<long> episodeFileIds);
}
@@ -50,11 +50,11 @@ public class LidarrClient : ArrClient, ILidarrClient
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
UriBuilder uriBuilder = new(arrInstance.Url);
@@ -85,6 +85,8 @@ public class LidarrClient : ArrClient, ILidarrClient
throw;
}
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
@@ -137,15 +139,14 @@ public class LidarrClient : ArrClient, ILidarrClient
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album";
uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using var response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<Album>>(responseBody);
return await DeserializeStreamAsync<List<Album>>(response);
}
private List<LidarrCommand> GetSearchCommands(HashSet<SearchItem> items)
@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -50,24 +51,24 @@ public class RadarrClient : ArrClient, IRadarrClient
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
RadarrCommand command = new()
{
Name = "MoviesSearch",
MovieIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
@@ -81,9 +82,18 @@ public class RadarrClient : ArrClient, IRadarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
if (response is null)
{
return [];
}
long? commandId = await ReadCommandIdAsync(response);
response.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
return commandId.HasValue ? [commandId.Value] : [];
}
catch
{
@@ -130,18 +140,77 @@ public class RadarrClient : ArrClient, IRadarrClient
return null;
}
public async Task<List<SearchableMovie>> GetAllMoviesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<SearchableMovie>>(reader) ?? [];
}
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return await DeserializeStreamAsync<List<ArrQualityProfile>>(response) ?? [];
}
public async Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds)
{
Dictionary<long, int> scores = new();
// Batch in chunks of 100 to avoid 414 URI Too Long
foreach (long[] batch in movieFileIds.Chunk(100))
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/moviefile";
uriBuilder.Query = string.Join('&', batch.Select(id => $"movieFileIds={id}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
List<MediaFileScore> files = await DeserializeStreamAsync<List<MediaFileScore>>(response) ?? [];
foreach (MediaFileScore file in files)
{
scores[file.Id] = file.CustomFormatScore;
}
}
return scores;
}
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Movie>(responseBody);
return await DeserializeStreamAsync<Movie>(response);
}
}
@@ -50,11 +50,11 @@ public class ReadarrClient : ArrClient, IReadarrClient
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
List<long> ids = items.Select(item => item.Id).ToList();
@@ -90,6 +90,8 @@ public class ReadarrClient : ArrClient, IReadarrClient
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0;
@@ -134,14 +136,13 @@ public class ReadarrClient : ArrClient, IReadarrClient
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Book>(responseBody);
return await DeserializeStreamAsync<Book>(response);
}
}
@@ -53,16 +53,18 @@ public class SonarrClient : ArrClient, ISonarrClient
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
List<long> commandIds = [];
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
@@ -78,8 +80,18 @@ public class SonarrClient : ArrClient, ISonarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
if (response is not null)
{
long? commandId = await ReadCommandIdAsync(response);
response.Dispose();
if (commandId.HasValue)
{
commandIds.Add(commandId.Value);
}
}
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
}
catch
@@ -88,6 +100,8 @@ public class SonarrClient : ArrClient, ISonarrClient
throw;
}
}
return commandIds;
}
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
@@ -195,35 +209,123 @@ public class SonarrClient : ArrClient, ISonarrClient
return null;
}
public async Task<List<SearchableSeries>> GetAllSeriesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
using StreamReader sr = new(stream);
using JsonTextReader reader = new(sr);
JsonSerializer serializer = JsonSerializer.CreateDefault();
return serializer.Deserialize<List<SearchableSeries>>(reader) ?? [];
}
public async Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = $"seriesId={seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return await DeserializeStreamAsync<List<SearchableEpisode>>(response) ?? [];
}
public async Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile";
uriBuilder.Query = $"seriesId={seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return await DeserializeStreamAsync<List<ArrEpisodeFile>>(response) ?? [];
}
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return await DeserializeStreamAsync<List<ArrQualityProfile>>(response) ?? [];
}
public async Task<Dictionary<long, int>> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List<long> episodeFileIds)
{
Dictionary<long, int> scores = new();
// Batch in chunks of 100 to avoid 414 URI Too Long
foreach (long[] batch in episodeFileIds.Chunk(100))
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile";
uriBuilder.Query = string.Join('&', batch.Select(id => $"episodeFileIds={id}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
List<MediaFileScore> files = await DeserializeStreamAsync<List<MediaFileScore>>(response) ?? [];
foreach (MediaFileScore file in files)
{
scores[file.Id] = file.CustomFormatScore;
}
}
return scores;
}
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<Episode>>(responseBody);
return await DeserializeStreamAsync<List<Episode>>(response);
}
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Series>(responseBody);
return await DeserializeStreamAsync<Series>(response);
}
private List<SonarrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
@@ -53,11 +53,11 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
UriBuilder uriBuilder = new(arrInstance.Url);
@@ -88,6 +88,8 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
throw;
}
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
@@ -204,10 +206,10 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage response = await SendRequestAsync(request);
string responseContent = await response.Content.ReadAsStringAsync();
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return JsonConvert.DeserializeObject<List<Episode>>(responseContent);
return await DeserializeStreamAsync<List<Episode>>(response);
}
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
@@ -218,10 +220,10 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage response = await SendRequestAsync(request);
string responseContent = await response.Content.ReadAsStringAsync();
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return JsonConvert.DeserializeObject<Series>(responseContent);
return await DeserializeStreamAsync<Series>(response);
}
private List<WhisparrV2Command> GetSearchCommands(HashSet<SeriesSearchItem> items)
@@ -51,11 +51,11 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
return [];
}
List<long> ids = items.Select(item => item.Id).ToList();
@@ -91,6 +91,8 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
return [];
}
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
@@ -135,14 +137,13 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Movie>(responseBody);
return await DeserializeStreamAsync<Movie>(response);
}
}
@@ -0,0 +1,57 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
public sealed record OidcAuthorizationResult
{
public required string AuthorizationUrl { get; init; }
public required string State { get; init; }
}
public sealed record OidcCallbackResult
{
public required bool Success { get; init; }
public string? Subject { get; init; }
public string? PreferredUsername { get; init; }
public string? Email { get; init; }
public string? Error { get; init; }
/// <summary>
/// The user ID of the authenticated user who initiated this OIDC flow.
/// Set when the flow is started from an authenticated context (e.g., account linking).
/// Used to verify the callback is completing the correct user's flow.
/// </summary>
public string? InitiatorUserId { get; init; }
}
public interface IOidcAuthService
{
/// <summary>
/// Generates the OIDC authorization URL and stores state/verifier for the callback.
/// </summary>
/// <param name="redirectUri">The callback URI for the OIDC provider.</param>
/// <param name="initiatorUserId">Optional user ID of the authenticated user initiating the flow (for account linking).</param>
Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null);
/// <summary>
/// Handles the OIDC callback: validates state, exchanges code for tokens, validates the ID token.
/// </summary>
Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri);
/// <summary>
/// Stores tokens associated with a one-time exchange code.
/// Returns the one-time code.
/// </summary>
string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn);
/// <summary>
/// Exchanges a one-time code for the stored tokens.
/// The code is consumed (can only be used once).
/// </summary>
OidcTokenExchangeResult? ExchangeOneTimeCode(string code);
}
public sealed record OidcTokenExchangeResult
{
public required string AccessToken { get; init; }
public required string RefreshToken { get; init; }
public required int ExpiresIn { get; init; }
}
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more