mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-10 12:31:24 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33e948d1e7 | ||
|
|
9f551f151e | ||
|
|
9447cb37c0 | ||
|
|
8183b324a0 | ||
|
|
a6a25de19c | ||
|
|
51a2a1b391 | ||
|
|
d7ab81ddcf | ||
|
|
8da07d4e93 | ||
|
|
20b93f6853 | ||
|
|
2ce204c1bc | ||
|
|
d653b7fa3f | ||
|
|
0dcb42efa2 | ||
|
|
820d254553 | ||
|
|
715ef5711b | ||
|
|
ea3244367e | ||
|
|
f26768bcf7 | ||
|
|
87bb92fac0 | ||
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
});
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+359
@@ -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
|
||||
}
|
||||
+166
@@ -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; }
|
||||
}
|
||||
+49
@@ -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 };
|
||||
}
|
||||
}
|
||||
-7
@@ -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;
|
||||
|
||||
+31
-7
@@ -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" });
|
||||
|
||||
+2
@@ -17,4 +17,6 @@ public abstract record CreateNotificationProviderRequestBase
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
}
|
||||
+2
@@ -17,4 +17,6 @@ public abstract record UpdateNotificationProviderRequestBase
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
}
|
||||
+32
-9
@@ -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
|
||||
};
|
||||
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+20
@@ -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; }
|
||||
}
|
||||
+8
@@ -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; }
|
||||
}
|
||||
+25
@@ -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; }
|
||||
}
|
||||
+16
@@ -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; }
|
||||
}
|
||||
+19
@@ -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; }
|
||||
}
|
||||
+12
@@ -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; } = [];
|
||||
}
|
||||
+26
@@ -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; } = [];
|
||||
}
|
||||
+24
@@ -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; }
|
||||
}
|
||||
+311
@@ -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
+7
-11
@@ -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);
|
||||
|
||||
|
||||
-166
@@ -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
|
||||
}
|
||||
-313
@@ -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
|
||||
}
|
||||
+77
-42
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+868
@@ -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
+9
@@ -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
Reference in new issue
Block a user