Compare commits

...
86 Commits
Author SHA1 Message Date
Flaminel c4b3adcf87 Fix external API response handling (#684) 2026-07-28 15:20:02 +03:00
Flaminel 075461391b Fix MacOS workflow permissions (#682) 2026-07-26 02:10:18 +03:00
Flaminel 2c1346f95f Add versioning policy (#679) 2026-07-26 01:54:30 +03:00
Flaminel e20fa1339d Update frontend packages (#681) 2026-07-26 01:47:02 +03:00
Flaminel 4dc8b5e051 Update docs packages (#680) 2026-07-26 01:37:55 +03:00
Flaminel dcdc07610a Add last active guard for qBittorrent seeding rules (#678) 2026-07-25 22:15:53 +03:00
Flaminel 6a6a7a79a7 Remove Newtonsoft.Json (#677) 2026-07-24 17:00:40 +03:00
Flaminel 282ec1d325 Improve Windows installer (#674) 2026-07-24 15:07:03 +03:00
Flaminel a28caf1bc9 Fix Windows installer failing to build (#676) 2026-07-24 13:50:38 +03:00
Flaminel 1fbfcc0803 Fix pr-build workflow missing permissions (#675) 2026-07-24 13:29:52 +03:00
Flaminel 5c5a217430 Add option to pause Queue Cleaner when the internet is unreachable (#673) 2026-07-24 10:59:22 +03:00
Flaminel ccdbb6294a Add option to ignore specific download clients for Queue Cleaner and Malware Blocker (#672) 2026-07-23 17:20:05 +03:00
Flaminel 107b0e7579 Add alternate speed option for slow download rules (#671) 2026-07-23 16:19:37 +03:00
Flaminel 4884f1f297 Update GitHub workflows (#670) 2026-07-21 12:04:52 +03:00
Flaminel 74ec19ae48 Add PostgreSQL support (#669) 2026-07-21 09:24:24 +03:00
Flaminel 6ae7592f6c Add workflow dispatch for docs preview cleanup(#668) 2026-07-14 14:30:59 +03:00
Flaminel 3089b61a7a Fix docs preview cleanup (#667) 2026-07-14 14:24:25 +03:00
Flaminel 18da58c346 Change docs root path (#666) 2026-07-14 14:13:30 +03:00
Flaminel 4d852911a5 Improve memory usage for Seeker and CF score sync (#663) 2026-07-10 16:24:06 +03:00
Flaminel 71113d8838 Address even more tech debt (#661) 2026-07-08 19:16:23 +03:00
Flaminel c4cddf8897 Fix docs and status workflows being skipped on release (#660) 2026-07-07 17:31:44 +03:00
Flaminel d7bb3195de Improve event history, add stats v2 endpoint and add graphs to show data (#656) 2026-07-07 17:02:48 +03:00
Flaminel 569cac9d61 Improve release workflow to include docs and status deployments (#658) 2026-07-04 13:46:19 +03:00
Flaminel 96844ac89c Disable e2e tests (#657) 2026-07-04 13:36:11 +03:00
Flaminel 9642ea263d Add bulk dismiss and lazy paging for action required events (#655) 2026-07-04 13:28:50 +03:00
Flaminel d34ddb841d Fix e2e pipelines (#654) 2026-07-04 03:12:43 +03:00
Flaminel 65afcaf3a4 Fix orphaned files name checks (#653) 2026-07-04 02:54:27 +03:00
Flaminel d784091ee4 Address some of the frontend tech debt (#648) 2026-07-03 14:53:13 +03:00
Flaminel 81cd7583cd Improve Plex login flow by redirecting instead of opening a new tab (#651) 2026-07-03 12:45:20 +03:00
Flaminel 3f6c3321e9 Fix user session being reset on transient refresh failure (#650) 2026-07-03 12:31:10 +03:00
Flaminel be3db1616e Upgrade frontend to Angular v22 (#644) 2026-07-01 21:16:26 +03:00
Flaminel 9fd99bd5f5 Fixed download directory mapping docs (#645) 2026-07-01 17:31:16 +03:00
Flaminel 8f1ea44466 Upgrade Docusaurus to v3.10.1 (#642) 2026-06-30 20:42:27 +03:00
Flaminel 6ec19a92b2 Fix docs sidebar on small screens (#641) 2026-06-30 17:37:28 +03:00
Flaminel fa1801875e Update frontend packages (#633) 2026-06-16 17:17:52 +03:00
Flaminel c6ef6ad979 Use ProblemDetails handling for the API (#632) 2026-06-16 15:26:56 +03:00
Flaminel 74f11f5beb Migrate from DateTime to DateTimeOffset (#629) 2026-06-15 17:15:06 +03:00
Flaminel c0950537ab Fix width of the recent events card (#630) 2026-06-14 17:49:32 +03:00
Flaminel 7cc079c61b Add new feature badge (#628) 2026-06-14 17:04:59 +03:00
Flaminel 7aa3224f4d Add dead torrent handling (#627) 2026-06-14 02:14:10 +03:00
Flaminel 1cc068c2ab Add Transmission label option for unlinked downloads (#626) 2026-06-13 23:09:26 +03:00
Flaminel 28f22f1085 Fix running orphaned files cleanup for unavailable clients (#624) 2026-06-01 00:36:02 +03:00
Flaminel 084f83efca Add option to remove malware if any file is blocked (#621) 2026-05-30 01:33:51 +03:00
Flaminel 26b76908eb Add min seeders to seeding rules (#620) 2026-05-28 17:23:41 +03:00
Flaminel ffc8a0a39a Fix some backgrounds not respecting the custom color (#619) 2026-05-27 20:08:43 +03:00
Flaminel 8ccd93dc97 Add orphaned files cleanup (#618) 2026-05-27 19:26:39 +03:00
Flaminel 1ca935b62b Fix Deluge crashing when removing malware (#308) 2026-05-15 03:53:05 +03:00
Flaminel 90a4909e57 Fix qBittorrent v5.2.0 login (#607) 2026-05-09 03:55:13 +03:00
Flaminel ef8fb2dd0b Fix flaky unit tests (#604) 2026-05-06 10:49:16 +03:00
Flaminel 20ad056400 Add seeding completed Transmission status for Download Cleaner (#603) 2026-05-05 16:53:12 +03:00
Flaminel ab792f5fad Add option to change the category instead of deleting queue items (#602) 2026-05-05 15:59:42 +03:00
Flaminel 48c36fab8f Add paused and queued Deluge states for Download Cleaner (#596) 2026-05-04 14:50:07 +03:00
Flaminel 3553fce597 Fix Seeker failing in dry run mode (#601) 2026-05-04 12:17:19 +03:00
Flaminel c1d2790c8d Fix value step for number inputs when using decimals (#599) 2026-05-03 23:11:31 +03:00
Flaminel 18a9c66ce7 Add name clarification (#598) 2026-05-03 01:40:13 +03:00
Flaminel 7ec60c7ea0 Update application description (#597) 2026-05-03 00:53:41 +03:00
Flaminel a96bc36a1e Add AI usage policy in contributing guide (#593) 2026-04-28 17:39:34 +03:00
Flaminel b575644d5f Improve docs layout (#570) 2026-04-28 16:22:07 +03:00
David Young 13bc71c3cd Add ElfHosted as managed deployment option in the docs (#590) 2026-04-28 13:54:03 +03:00
Flaminel c3f3ee880d Add UI warning when not linking an OIDC account (#587) 2026-04-27 13:19:46 +03:00
Flaminel 8ab4a55595 Remove CORS policy for production environments (#588) 2026-04-27 13:13:06 +03:00
Flaminel 85de80a463 Fix forwarded header parsing (#586) 2026-04-27 12:49:33 +03:00
Flaminel 9f48d3565a Add customizable theme color (#582) 2026-04-26 01:28:07 +03:00
Mike Lonergan db2e3e71db Fix number input eager clamping on keystrokes (#583) 2026-04-26 01:06:26 +03:00
Flaminel 5d400ad854 Fix Custom Format score syncer not being scheduled accordingly (#584) 2026-04-25 23:57:12 +03:00
Flaminel 02a07d4fa3 Add sorting and filters for Seeker stats (#576) 2026-04-25 11:57:38 +03:00
Flaminel 41ca55d615 Fix log config inputs not allowing for zero (#579) 2026-04-22 16:15:56 +03:00
Flaminel 8770a8b18e Add hour intervals for Seeker (#578) 2026-04-21 16:56:58 +03:00
Flaminel 24ecd88cd0 Fix backend page size to be in sync with the UI (#577) 2026-04-21 16:29:59 +03:00
Flaminel 7ed0f307be Add customizable pagination size (#575) 2026-04-20 19:51:02 +03:00
Flaminel 5b500c533e Improve the scrollbar position and visibility (#574) 2026-04-20 15:40:19 +03:00
Flaminel b1ef63ef43 Add sticky headers for pages with tables (#573) 2026-04-20 15:37:45 +03:00
Flaminel f14e345b52 Add full width UI toggle (#572) 2026-04-20 15:33:04 +03:00
Flaminel 89a0d1281f Fix Seeker's filters allowing searching for all types of movies regardless of settings (#567) 2026-04-16 18:21:36 +03:00
Flaminel ee5e7c0819 Replace Moq with NSubstitute for unit tests (#566) 2026-04-16 12:13:36 +03:00
Flaminel d875d88191 Add per-instance Seeker settings (#565) 2026-04-15 23:16:16 +03:00
Flaminel 447db6990a Add item grabbed notification (#564) 2026-04-15 21:51:09 +03:00
Flaminel 4e9d20db0a Improve seeding rule customization (#553) 2026-04-11 17:13:41 +03:00
Flaminel 53fc5eff3b Fix qBittorrent tracker fetching (#555) 2026-04-10 21:00:28 +03:00
Flaminel 69fa09e23a Fix disabled arr instances being processed in some cases (#554) 2026-04-09 14:41:32 +03:00
Flaminel 3360b7a849 Fix db migration not being applied (#552) 2026-04-07 11:51:07 +03:00
Flaminel 80b46df8e5 Add search event reason (#546) 2026-04-06 09:59:31 +03:00
Flaminel 88aa71c343 Fix duplicated grabbed items reported for Sonarr on season packs (#549) 2026-04-05 22:11:48 +03:00
Flaminel 7b80e038cc Fix rTorrent path evaluation for items without a subdirectory (#548) 2026-04-05 22:11:22 +03:00
Flaminel ef280ec398 Fix queue rules upper bound to never allow 0 (#543) 2026-04-03 16:11:12 +03:00
Flaminel 81f6de03e7 Improve Seeker's total number description (#542) 2026-04-03 15:59:23 +03:00
1098 changed files with 118441 additions and 42086 deletions

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ runs:
using: "composite"
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ inputs.vault_host }}
method: approle
+25
View File
@@ -0,0 +1,25 @@
changelog:
exclude:
labels:
- duplicate
- invalid
- wontfix
categories:
- title: Breaking Changes
labels:
- breaking
- title: Features
labels:
- enhancement
- title: Bug Fixes
labels:
- bug
- title: Documentation
labels:
- documentation
- title: Maintenance
labels:
- chore
- title: Other Changes
labels:
- "*"
+12 -11
View File
@@ -133,7 +133,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -144,23 +144,24 @@ jobs:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout target repository
uses: actions/checkout@v4
uses: actions/checkout@v7
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ needs.prepare.outputs.branch }}
token: ${{ env.REPO_READONLY_PAT }}
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
timeout-minutes: 5
- name: Login to GitHub Container Registry
if: needs.prepare.outputs.push == 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -170,7 +171,7 @@ jobs:
if: needs.prepare.outputs.push == 'true'
id: build-push
timeout-minutes: 30
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
@@ -190,7 +191,7 @@ jobs:
- name: Build (no push)
if: needs.prepare.outputs.push != 'true'
timeout-minutes: 30
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
@@ -216,7 +217,7 @@ jobs:
- name: Upload digest
if: needs.prepare.outputs.push == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@@ -230,17 +231,17 @@ jobs:
if: needs.prepare.outputs.push == 'true'
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+10 -6
View File
@@ -9,6 +9,9 @@ on:
required: false
default: ''
permissions:
contents: read
jobs:
# Build for each platform in parallel using matrix strategy
build-platform:
@@ -55,7 +58,7 @@ jobs:
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -66,20 +69,21 @@ jobs:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout target repository
uses: actions/checkout@v4
uses: actions/checkout@v7
timeout-minutes: 1
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
persist-credentials: false
- name: Setup dotnet
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
@@ -87,7 +91,7 @@ jobs:
${{ runner.os }}-nuget-
- name: Download frontend artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
@@ -119,7 +123,7 @@ jobs:
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: executable-${{ matrix.platform }}
path: ./artifacts/*.zip
+9 -5
View File
@@ -9,12 +9,15 @@ on:
required: false
default: ''
permissions:
contents: read
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -24,17 +27,18 @@ jobs:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ inputs.ref || github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
@@ -45,7 +49,7 @@ jobs:
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
+7 -6
View File
@@ -1,7 +1,7 @@
name: Build macOS Installers
permissions:
contents: write
contents: read
on:
workflow_call:
@@ -59,7 +59,7 @@ jobs:
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -70,21 +70,22 @@ jobs:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
fetch-depth: 0
persist-credentials: false
- name: Download frontend artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.200
@@ -369,7 +370,7 @@ jobs:
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
- name: Upload installer as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
path: '${{ env.pkgName }}'
+11 -14
View File
@@ -14,6 +14,9 @@ on:
required: false
default: ''
permissions:
contents: read
jobs:
build-windows-installer:
runs-on: windows-latest
@@ -49,7 +52,7 @@ jobs:
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -60,20 +63,21 @@ jobs:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: ${{ env.githubRepository }}
ref: ${{ inputs.ref || github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
persist-credentials: false
- name: Download frontend artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.200
@@ -95,14 +99,7 @@ jobs:
- name: Setup Inno Setup
shell: pwsh
run: |
# Download and install Inno Setup
$url = "https://jrsoftware.org/download.php/is.exe"
$output = "innosetup-installer.exe"
Invoke-WebRequest -Uri $url -OutFile $output
Start-Process -FilePath $output -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" -Wait
# Add Inno Setup to PATH
choco install innosetup --no-progress -y
$innoPath = "C:\Program Files (x86)\Inno Setup 6"
echo "$innoPath" >> $env:GITHUB_PATH
@@ -143,10 +140,10 @@ jobs:
echo "installerName=$installerName" >> $env:GITHUB_ENV
- name: Upload installer artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: Cleanuparr-windows-installer
path: installer/${{ env.installerName }}
retention-days: 30
# Removed individual release step - handled by main release workflow
# Removed individual release step - handled by main release workflow
@@ -12,6 +12,9 @@ on:
- 'whitelist_with_subtitles'
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
@@ -19,7 +22,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Create directory for static files
run: |
@@ -33,8 +38,9 @@ jobs:
cp whitelist_with_subtitles Cloudflare/static/
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: "Cloudflare"
command: pages deploy . --project-name=cleanuparr
command: pages deploy . --project-name=cleanuparr
+21 -8
View File
@@ -1,20 +1,32 @@
name: Deploy to Cloudflare Pages
name: Deploy Status Page to Cloudflare Pages
on:
push:
tags:
- "v*.*.*"
workflow_call:
inputs:
version:
description: 'Release version (e.g. v1.2.3)'
type: string
required: true
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. v1.2.3)'
type: string
required: true
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
name: Deploy Status Page to Cloudflare Pages
steps:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
echo "{ \"version\": \"${{ inputs.version }}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
@@ -23,8 +35,9 @@ jobs:
EOF
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: "status"
command: pages deploy . --project-name=cleanuparr-status
command: pages deploy . --project-name=cleanuparr-status
+5 -3
View File
@@ -19,10 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5
with:
# Fail on critical and high severity vulnerabilities
fail-on-severity: high
@@ -37,7 +39,7 @@ jobs:
vulnerability-check: true
- name: Upload dependency review results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: dependency-review-results
path: dependency-review-*.json
@@ -0,0 +1,63 @@
name: Docs Preview Cleanup
on:
pull_request:
types: [closed]
paths:
- 'docs/**'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number whose preview deployments to delete'
required: true
type: string
permissions:
contents: read
jobs:
cleanup:
runs-on: ubuntu-latest
# Skip fork PRs
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PROJECT: cleanuparr-docs
BRANCH: pull-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Delete preview deployments for this PR
run: |
set -euo pipefail
api="https://api.cloudflare.com/client/v4"
auth="Authorization: Bearer $CF_API_TOKEN"
if [ -z "${CF_ACCOUNT_ID:-}" ]; then
echo "CLOUDFLARE_ACCOUNT_ID repo variable is not set" >&2
exit 1
fi
echo "Account $CF_ACCOUNT_ID, tearing down branch $BRANCH"
ids=""
page=1
while :; do
resp=$(curl -fsS -H "$auth" \
"$api/accounts/$CF_ACCOUNT_ID/pages/projects/$PROJECT/deployments?page=$page&per_page=25")
count=$(echo "$resp" | jq '.result | length')
[ "$count" -eq 0 ] && break
page_ids=$(echo "$resp" | jq -r --arg b "$BRANCH" \
'.result[] | select(.deployment_trigger.metadata.branch == $b) | .id')
ids="$ids $page_ids"
page=$((page + 1))
done
deleted=0
for id in $ids; do
echo "Deleting deployment $id"
curl -fsS -X DELETE -H "$auth" \
"$api/accounts/$CF_ACCOUNT_ID/pages/projects/$PROJECT/deployments/$id?force=true" >/dev/null
deleted=$((deleted + 1))
done
echo "Deleted $deleted deployment(s) for $BRANCH"
+85
View File
@@ -0,0 +1,85 @@
name: Docs Preview
on:
pull_request:
paths:
- 'docs/**'
concurrency:
group: docs-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
preview:
runs-on: ubuntu-latest
# Skip fork PRs
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 26.x
cache: yarn
cache-dependency-path: docs/yarn.lock
- name: Install dependencies
working-directory: docs
run: yarn install --frozen-lockfile
- name: Build Docusaurus
working-directory: docs
run: yarn build
- name: Deploy preview to Cloudflare Pages
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: docs
command: pages deploy build --project-name=cleanuparr-docs --branch=pull-${{ github.event.pull_request.number }}
- name: Comment preview URL
uses: actions/github-script@v9
with:
script: |
const url = `https://pull-${context.issue.number}.cleanuparr-docs.pages.dev`;
const marker = '<!-- docs-preview -->';
const body = [
marker,
`Docs preview for \`${context.payload.pull_request.head.ref}\` (\`${context.payload.pull_request.head.sha.substring(0, 7)}\`):`,
``,
`${url}/docs`
].join('\n');
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
}
+38 -15
View File
@@ -1,9 +1,7 @@
name: Deploy Docusaurus to GitHub Pages
on:
push:
tags:
- "v*.*.*"
workflow_call: {}
workflow_dispatch: {}
permissions:
@@ -12,22 +10,20 @@ permissions:
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 24.x
node-version: 26.x
cache: yarn
cache-dependency-path: docs/yarn.lock
@@ -39,12 +35,39 @@ jobs:
working-directory: docs
run: yarn build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
- name: Publish to org site
uses: peaceiris/actions-gh-pages@v4
with:
path: docs/build
personal_token: ${{ secrets.DOCS_DEPLOY_TOKEN }}
external_repository: Cleanuparr/Cleanuparr.github.io
publish_branch: gh-pages
publish_dir: docs/build
redirect:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Build redirect stub
run: |
mkdir redirect
cat > redirect/404.html <<'EOF'
<!doctype html>
<meta http-equiv="refresh" content="0;url=https://cleanuparr.github.io/docs">
<script>
var p = location.pathname.replace(/^\/Cleanuparr/, '');
location.replace('https://cleanuparr.github.io' + p + location.search + location.hash);
</script>
EOF
cp redirect/404.html redirect/index.html
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: redirect
retention-days: 1
- name: Deploy to GitHub Pages
- name: Deploy redirect to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+24 -9
View File
@@ -24,16 +24,30 @@ permissions:
jobs:
e2e:
if: false
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
suite:
- name: api
make-target: up-api
- name: download-clients
make-target: up-dc
name: e2e (${{ matrix.suite.name }})
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
timeout-minutes: 1
with:
persist-credentials: false
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -44,15 +58,15 @@ jobs:
- name: Start services
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml up -d --build
run: make ${{ matrix.suite.make-target }}
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 22
node-version: 26
- name: Install E2E dependencies
working-directory: e2e
@@ -60,7 +74,8 @@ jobs:
- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium
timeout-minutes: 5
run: npx playwright install chromium
- name: Wait for Keycloak
run: |
@@ -76,13 +91,13 @@ jobs:
- name: Run E2E tests
working-directory: e2e
run: npx playwright test
run: npx playwright test --project=${{ matrix.suite.name }}
- name: Upload test results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-test-results
name: e2e-test-results-${{ matrix.suite.name }}
path: |
e2e/playwright-report/
e2e/test-results/
+6 -3
View File
@@ -9,6 +9,7 @@ concurrency:
cancel-in-progress: true
permissions:
contents: read
issues: write
pull-requests: write
actions: read
@@ -26,7 +27,7 @@ jobs:
steps:
- name: Parse command and check permissions
id: parse
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const comment = context.payload.comment.body.trim();
@@ -136,12 +137,14 @@ jobs:
steps:
- name: Post result comment
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
PR_REF: ${{ needs.validate.outputs.pr_ref }}
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 prRef = process.env.PR_REF;
const prSha = '${{ needs.validate.outputs.pr_sha }}';
const shortSha = prSha.substring(0, 7);
+29
View File
@@ -0,0 +1,29 @@
name: PR Label
on:
pull_request:
types: [opened, reopened, synchronize, labeled, unlabeled]
concurrency:
group: PR Label-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
category:
name: Require category label
runs-on: ubuntu-latest
steps:
- name: Fail when no category label is present
if: >-
!contains(github.event.pull_request.labels.*.name, 'breaking') &&
!contains(github.event.pull_request.labels.*.name, 'enhancement') &&
!contains(github.event.pull_request.labels.*.name, 'bug') &&
!contains(github.event.pull_request.labels.*.name, 'documentation') &&
!contains(github.event.pull_request.labels.*.name, 'chore')
run: |
echo "::error::Add one of these labels so the release notes can be categorised: breaking, enhancement, bug, documentation, chore"
exit 1
+94 -7
View File
@@ -35,10 +35,16 @@ on:
required: false
default: false
permissions:
contents: read
jobs:
# Validate release
validate:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
@@ -46,7 +52,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Get version info
id: version
@@ -89,6 +97,59 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check the version against PR labels
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APP_VERSION: ${{ steps.version.outputs.app_version }}
run: |
previous_tag=$(gh release view --json tagName --jq .tagName 2>/dev/null || echo "")
if [ -z "$previous_tag" ]; then
echo "No previous release found. Skipping the label check."
exit 0
fi
published=$(gh release view "$previous_tag" --json publishedAt --jq .publishedAt)
if ! breaking_list=$(gh api --paginate "repos/${{ github.repository }}/issues?labels=breaking&state=closed&since=$published&per_page=100" \
--jq ".[] | select(.pull_request.merged_at > \"$published\") | \" #\(.number) \(.title)\""); then
echo "::error::Failed to query pull request labels, cannot verify the version"
printf '%s\n' "$breaking_list"
exit 1
fi
breaking=$(printf '%s' "$breaking_list" | grep -c . || true)
previous_version=${previous_tag#v}
if [ "$previous_version" = "$APP_VERSION" ]; then
echo "::error::$APP_VERSION is the same version as the previous release $previous_tag."
exit 1
fi
if [ "$(printf '%s\n%s\n' "$previous_version" "$APP_VERSION" | sort -V | tail -1)" != "$APP_VERSION" ]; then
echo "::error::$APP_VERSION is older than the previous release $previous_tag. Releases must move forward."
exit 1
fi
previous_minor=${previous_version%.*}
new_minor=${APP_VERSION%.*}
echo "Previous release: $previous_tag (published $published)"
echo "Breaking PRs merged since: $breaking"
if [ "$breaking" -gt 0 ] && [ "$previous_minor" = "$new_minor" ]; then
echo "::error::$breaking PR(s) labelled 'breaking' were merged since $previous_tag, so $APP_VERSION cannot be a patch release. Bump the minor, or remove the label if it was applied by mistake. See RELEASING.md."
printf '%s\n' "$breaking_list"
exit 1
fi
if [ "$breaking" -eq 0 ] && [ "$previous_minor" != "$new_minor" ]; then
echo "::warning::$APP_VERSION bumps the minor but no PR merged since $previous_tag carries the 'breaking' label. Intentional for a policy or milestone release, otherwise check the labels."
fi
echo "✅ Version $APP_VERSION is consistent with the PR labels."
# Run tests
test:
needs: validate
@@ -99,7 +160,8 @@ jobs:
# Run E2E tests
e2e:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
# if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
if: false
uses: ./.github/workflows/e2e.yml
secrets: inherit
@@ -163,6 +225,9 @@ jobs:
# Build and push Docker image(s)
build-docker:
needs: [validate, test, e2e]
permissions:
contents: read
packages: write
if: |
always() &&
needs.validate.result == 'success' &&
@@ -192,7 +257,7 @@ jobs:
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -202,20 +267,20 @@ jobs:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Download executable artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: executable-*
path: ./artifacts
merge-multiple: true
- name: Download Windows installer
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: Cleanuparr-windows-installer
path: ./artifacts
- name: Download macOS installers
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: Cleanuparr-macos-*-installer
path: ./artifacts
@@ -229,7 +294,7 @@ jobs:
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
- name: Create release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
name: ${{ needs.validate.outputs.release_version }}
tag_name: ${{ needs.validate.outputs.release_version }}
@@ -242,10 +307,32 @@ jobs:
./artifacts/*.pkg
./artifacts/*.exe
# Deploy docs after a successful release
deploy-docs:
needs: [create-release]
if: always() && needs.create-release.result == 'success'
permissions:
contents: read
pages: write
id-token: write
uses: ./.github/workflows/docs.yml
secrets: inherit
# Deploy Cloudflare status page after a successful release
deploy-status:
needs: [validate, create-release]
if: always() && needs.create-release.result == 'success'
uses: ./.github/workflows/cloudflare-pages-status.yml
with:
version: ${{ needs.validate.outputs.release_version }}
secrets: inherit
# Summary job
summary:
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
runs-on: ubuntu-latest
permissions:
actions: read
if: always()
steps:
+13 -7
View File
@@ -18,23 +18,29 @@ concurrency:
group: Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
timeout-minutes: 1
with:
persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
@@ -42,7 +48,7 @@ jobs:
${{ runner.os }}-nuget-
- name: Get vault secrets
uses: hashicorp/vault-action@v2
uses: hashicorp/vault-action@v4
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
@@ -64,21 +70,21 @@ jobs:
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
- name: Upload test results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: test-results
path: ./coverage/*.trx
retention-days: 30
- name: Upload coverage reports
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: ./coverage/**/coverage.cobertura.xml
retention-days: 30
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v7
with:
files: ./coverage/**/coverage.cobertura.xml
token: ${{ secrets.CODECOV_TOKEN }}
+3
View File
@@ -22,6 +22,9 @@ on:
description: 'Repository name without owner'
value: ${{ jobs.version.outputs.repository_name }}
permissions:
contents: read
jobs:
version:
runs-on: ubuntu-latest
+11 -5
View File
@@ -46,11 +46,13 @@ Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **Angular 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **State Management**: Angular signals (`signal`/`computed`/`effect`) — `@ngrx/signals` was removed (it was unused)
- **Data fetching**: Angular 22 Resource API — `rxResource` from `@angular/core/rxjs-interop` (not manual `HttpClient.subscribe()`)
- **Forms**: Angular 22 Signal Forms — `form()` + `[formField]` from `@angular/forms/signals` (settings forms; a few not-yet-migrated forms still use per-field signals)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
@@ -69,7 +71,7 @@ Cleanuparr/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 21 application
│ ├── frontend/ # Angular 22 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
@@ -98,6 +100,8 @@ Cleanuparr/
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- **Data fetching**: use the **Resource API** (`rxResource`) with a reactive `params` + `stream`, not manual `HttpClient.subscribe()`; drive spinners/errors off `isLoading()`/`error()`
- **Forms**: use **Signal Forms** (`form()` + `[formField]`) with a single model signal + schema validators; keep the JSON-snapshot dirty tracking (`buildSnapshot()`/`hasPendingChanges()`), do NOT use Signal Forms `dirty()` for the unsaved-changes guard
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
- Component naming: `{feature}.component.ts`
@@ -183,7 +187,9 @@ make migrate-users name=YourMigrationName
- **Malware blocker** is a critical security feature - changes require careful testing
- **Cross-seed integration** allows keeping torrents that are actively seeding
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
- The project uses **Clean Architecture** - respect layer boundaries
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved"
- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading
- **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min)
+8 -1
View File
@@ -4,6 +4,13 @@ Thanks for your interest in contributing to Cleanuparr! This guide will help you
## Before You Start
### AI usage
In this ever-evolving field of work, AI is now the shiny new tool to help programmers work faster and there's nothing wrong with that.
But it is **very** wrong to rely solely on AI tools to write, review and test your code.
**If you do not have a background in programming and you do not intend to test your code properly, please do not submit AI-generated code.** If you still want to help in other ways such as testing features, that would also help a lot!
### Announce Your Intent
Before starting any work, please let us know what you want to contribute:
@@ -20,7 +27,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 18+](https://nodejs.org/)
- [Node.js 26+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
+12 -9
View File
File diff suppressed because one or more lines are too long.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 69 KiB

+11 -1
View File
@@ -1,7 +1,11 @@
<div align="center">
_Love this project? Give it a ⭐️ and let others know!_
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
_/kliː.nʌp.ər/ — like "cleanuper", someone who does the cleanup. Not "CleanupArr" or "CleanUpArr"._
![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcleanuparr-status.pages.dev%2Fstatus.json&query=%24.version&logo=git&label=version&color=blue)
![Total Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2FCleanuparr%2FCleanuparr%2Fcleanuparr&query=%24.downloadCount&style=flat&logo=docker&label=Total%20Downloads&color=blue)
[![Tests](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
@@ -9,7 +13,9 @@ _Love this project? Give it a ⭐️ and let others know!_
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/SCtMCgtsc4)
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
</div>
Cleanuparr is an advanced download manager for the Servarr ecosystem. It works with Sonarr, Radarr, Lidarr, Readarr, and Whisparr alongside download clients like qBittorrent, Transmission, and Deluge. Beyond cleaning up stalled, blocked, and malicious downloads, it searches for missing content and quality upgrades, manages seeding, and removes orphaned files.
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
@@ -28,6 +34,7 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Search for **custom format score upgrades** with automatic score tracking.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
@@ -46,6 +53,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
- **Lidarr**
- **Readarr**
- **Whisparr v2**
- **Whisparr v3**
### Download Clients (latest version)
- **qBittorrent**
@@ -77,6 +85,8 @@ docker run -d --name cleanuparr \
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
> Prefer not to self-host? A managed Cleanuparr instance is available via [ElfHosted](https://store.elfhosted.com/product-category/personal-stacks/?utm_source=github&utm_medium=readme&utm_campaign=cleanuparr-readme), bundled alongside Sonarr/Radarr to keep your queues tidy (7-day trial).
### 🌐 Access the Web Interface
After installation, open your browser and navigate to:
+87
View File
@@ -0,0 +1,87 @@
# Releasing Cleanuparr
Versioning rules and the release process are detailed in the user-facing version of this policy at [docs/docs/installation/versioning.mdx](docs/docs/installation/versioning.mdx) and published under [Versioning](https://cleanuparr.github.io/docs/installation/versioning).
## Versioning
This policy takes effect starting with **v2.10.0**. Previous releases did not follow this approach, and their version numbers have no compatibility meaning.
| Part | Bump when |
| --- | --- |
| Major | Probably never, unless something significant changes. |
| Minor | The release contains a breaking change. |
| Patch | Anything else: bug fixes and new features. |
## Deciding the number
Every pull request includes a category label, which is enforced by [pr-label.yml](.github/workflows/pr-label.yml). The `breaking` label determines the version number.
From the `code` directory, use this command to list every breaking pull request that was merged since the last release and indicate which number to bump:
```bash
make release-check
```
To view the full release notes for a version before tagging it, without creating anything:
```bash
make release-notes version=2.11.0
```
A `## Breaking Changes` section means the release is a minor version. If this section is absent, the release is a patch.
[release.yml](.github/workflows/release.yml) performs the same check and stops the release process before anything is built if breaking pull requests were merged but the minor version did not increment. It issues a warning but continues if the minor version moves without a breaking pull request leading it.
Examples of breaking changes:
- A migration that drops or rewrites data, preventing rollback to the previous version
- Renaming or removing an environment variable
- Dropping a platform, architecture, installer, or database provider
- A default value that alters how an existing configuration interacts with user data
- A setting whose unit or interpretation changes and cannot be altered by a migration
- Dropping a download client or *arr application, or increasing the minimum supported version
- A change to the public REST API (e.g., the `stats` endpoint)
Examples of non-breaking changes:
- A migration that simply adds columns or tables
- Renaming any value stored in the database and configured through the UI, accompanied by a migration
- Any change to the private REST API, which is intended only for the UI
- Modifications to the Web UI layout, internal structure, or logging
## Auditing migrations before a release
The easiest breaking change to overlook is a migration that prevents rollback. To see the migrations added since the previous tag:
```bash
git diff --name-only <last-tag>..main -- '*/Migrations/**' \
| grep -v -e Designer -e Snapshot \
| xargs grep -l 'DropColumn\|DropTable\|AlterColumn\|RenameColumn\|DeleteData\|migrationBuilder\.Sql'
```
Only `Up()` matters, since every migration drops things in `Down()` by definition, leading to false positives in that grep.
Additive changes are safe. `AddColumn` and `CreateTable` allow previous versions to start. However, a conversion or rewrite does not.
## Release process
Tag pushes that match `v*.*.*` trigger [release.yml](.github/workflows/release.yml).
```bash
git tag v2.10.0
git push origin v2.10.0
```
### Release notes
GitHub generates notes from the pull requests merged since the last tag, grouped into sections by label according to [release.yml](.github/release.yml):
| Label | Section |
| --- | --- |
| `breaking` | Breaking Changes |
| `enhancement` | Features |
| `bug` | Bug Fixes |
| `documentation` | Documentation |
| `chore` | Maintenance |
The status check for `Require category label` is mandatory on `main`, so a missing label will be caught before merging. Labels added after merging still apply because notes are generated at release time.
+6 -1
View File
@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -68,6 +68,11 @@ ENV PUID=1000 \
# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
# GC tuning: prefer lower memory footprint over throughput
ENV DOTNET_GCDynamicAdaptationMode=1 \
DOTNET_GCConserveMemory=5 \
DOTNET_GCRetainVM=0
WORKDIR /app
# Copy backend
+53 -3
View File
@@ -7,19 +7,69 @@ migrate-data:
ifndef name
$(error name is required. Usage: make migrate-data name=YourMigrationName)
endif
dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
migrate-events:
ifndef name
$(error name is required. Usage: make migrate-events name=YourMigrationName)
endif
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
migrate-users:
ifndef name
$(error name is required. Usage: make migrate-users name=YourMigrationName)
endif
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
PG_DUMMY = DATABASE_PROVIDER=postgres POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASS=postgres POSTGRES_DB=cleanuparr
migrate-postgres-data:
ifndef name
$(error name is required. Usage: make migrate-postgres-data name=YourMigrationName)
endif
$(PG_DUMMY) dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
migrate-postgres-events:
ifndef name
$(error name is required. Usage: make migrate-postgres-events name=YourMigrationName)
endif
$(PG_DUMMY) dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
migrate-postgres-users:
ifndef name
$(error name is required. Usage: make migrate-postgres-users name=YourMigrationName)
endif
$(PG_DUMMY) dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
release-check:
@previous=$$(gh release view --json tagName --jq .tagName) || exit 1; \
published=$$(gh release view --json publishedAt --jq .publishedAt); \
echo "Previous release: $$previous ($$published)"; \
echo ""; \
if ! breaking=$$(gh api --paginate "repos/{owner}/{repo}/issues?labels=breaking&state=closed&since=$$published&per_page=100" \
--jq ".[] | select(.pull_request.merged_at > \"$$published\") | \" #\(.number) \(.title)\""); then \
echo "Failed to query pull request labels:" >&2; \
echo "$$breaking" >&2; \
exit 1; \
fi; \
if [ -n "$$breaking" ]; then \
echo "Breaking changes merged since $$previous:"; \
echo "$$breaking"; \
echo ""; \
echo "=> the next release must bump the MINOR"; \
else \
echo "No breaking changes merged since $$previous"; \
echo ""; \
echo "=> the next release can be a PATCH"; \
fi
release-notes:
ifndef version
$(error version is required. Usage: make release-notes version=2.11.0)
endif
@previous=$$(gh release view --json tagName --jq .tagName); \
gh api -X POST repos/{owner}/{repo}/releases/generate-notes \
-f tag_name=v$(version) -f target_commitish=main -f previous_tag_name=$$previous --jq .body
docker-build:
ifndef tag
@@ -0,0 +1,102 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Controllers;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Providers;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Controllers;
/// <summary>
/// Verifies the events list endpoint's ordering, filtering, search, and primitive-collection round-tripping.
/// Runs against real SQLite so the projection is actually translated.
/// </summary>
public class EventsControllerMergeTests : IDisposable
{
private readonly EventsContext _context;
private readonly EventsController _controller;
public EventsControllerMergeTests()
{
_context = SeekerTestDataFactory.CreateEventsContext();
_controller = new EventsController(_context, new SqliteDatabaseProvider());
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private async Task SeedAsync()
{
_context.Events.Add(new AppEvent
{
EventType = EventType.FailedImportStrike,
Message = "active",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-1),
ItemTitle = "Active Item",
FailedImportReasons = ["reason one", "reason two"],
GrabbedItems = ["grab one"],
});
_context.Events.Add(new AppEvent
{
EventType = EventType.QueueItemDeleted,
Message = "archived",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-100),
ItemTitle = "Archived Item",
});
await _context.SaveChangesAsync();
}
private static PaginatedResult<EventListItem> GetPage(ActionResult<PaginatedResult<EventListItem>> action)
{
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
return ok.Value.ShouldBeOfType<PaginatedResult<EventListItem>>();
}
[Fact]
public async Task GetEvents_OrdersNewestFirst_AndRoundTripsCollections()
{
await SeedAsync();
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents());
page.TotalCount.ShouldBe(2);
page.Items.Count.ShouldBe(2);
page.Items[0].Message.ShouldBe("active"); // newer
page.Items[1].Message.ShouldBe("archived");
// The primitive-collection columns must survive the Concat projection.
page.Items[0].FailedImportReasons.ShouldBe(["reason one", "reason two"]);
page.Items[0].GrabbedItems.ShouldBe(["grab one"]);
}
[Fact]
public async Task GetEvents_EventTypeFilter_Applies()
{
await SeedAsync();
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(eventType: nameof(EventType.QueueItemDeleted)));
page.TotalCount.ShouldBe(1);
page.Items[0].Message.ShouldBe("archived");
}
[Fact]
public async Task GetEvents_SearchFilter_MatchesArchivedItemTitle()
{
await SeedAsync();
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(search: "Archived"));
page.TotalCount.ShouldBe(1);
page.Items[0].Message.ShouldBe("archived");
}
}
@@ -0,0 +1,135 @@
using Cleanuparr.Api.Controllers;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Providers;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Controllers;
public class EventsControllerTimelineTests : IDisposable
{
private readonly EventsContext _context;
private readonly EventsController _controller;
public EventsControllerTimelineTests()
{
_context = SeekerTestDataFactory.CreateEventsContext();
_controller = new EventsController(_context, new SqliteDatabaseProvider());
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private static EventTypeTimelineResponse GetTimeline(ActionResult<EventTypeTimelineResponse> action)
{
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
return ok.Value.ShouldBeOfType<EventTypeTimelineResponse>();
}
[Fact]
public async Task GetTimeline_BucketsEventsByTypeAndDay()
{
DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime);
DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero);
_context.Events.Add(new AppEvent
{
EventType = EventType.FailedImportStrike,
Message = "active a",
Severity = EventSeverity.Important,
Timestamp = sameDay,
});
_context.Events.Add(new AppEvent
{
EventType = EventType.FailedImportStrike,
Message = "active b",
Severity = EventSeverity.Important,
Timestamp = sameDay.AddHours(-1),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "active c",
Severity = EventSeverity.Important,
Timestamp = sameDay.AddHours(-2),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.QueueItemDeleted,
Message = "older removal",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-10),
});
await _context.SaveChangesAsync();
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30));
timeline.Types.ShouldBe(["FailedImportStrike", "StalledStrike", "QueueItemDeleted"]);
int failedImport = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("FailedImportStrike"));
int stalled = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("StalledStrike"));
int removed = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("QueueItemDeleted"));
failedImport.ShouldBe(2);
stalled.ShouldBe(1);
removed.ShouldBe(1);
DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart);
todayBucket.Counts["FailedImportStrike"].ShouldBe(2);
todayBucket.Counts["StalledStrike"].ShouldBe(1);
todayBucket.Counts.ShouldNotContainKey("QueueItemDeleted");
}
[Fact]
public async Task GetTimeline_UsesHourlyBucketsForDayWindow()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "recent",
Severity = EventSeverity.Important,
Timestamp = now.AddHours(-1),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "earlier",
Severity = EventSeverity.Important,
Timestamp = now.AddHours(-3),
});
await _context.SaveChangesAsync();
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24));
int nonEmpty = timeline.Buckets.Count(b => b.Counts.GetValueOrDefault("StalledStrike") > 0);
nonEmpty.ShouldBe(2);
timeline.Buckets.Count.ShouldBeGreaterThan(2);
}
[Fact]
public async Task GetTimeline_ExcludesEventsOutsideWindow()
{
_context.Events.Add(new AppEvent
{
EventType = EventType.QueueItemDeleted,
Message = "too old",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
});
await _context.SaveChangesAsync();
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 7));
timeline.Types.ShouldBeEmpty();
timeline.Buckets.ShouldAllBe(b => b.Counts.Count == 0);
}
}
@@ -0,0 +1,66 @@
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace Cleanuparr.Api.Tests.Events;
/// <summary>
/// Exercises the manual-event partial unique index against a real SQLite context configured with the
/// production naming conventions. The EF Core InMemory provider ignores unique indexes, so the guarantee
/// that <see cref="Cleanuparr.Infrastructure.Events.EventPublisher.PublishManualAsync"/> relies on to
/// dedup racing publishers can only be verified here.
/// </summary>
public class ManualEventDedupTests : IDisposable
{
private readonly EventsContext _context;
public ManualEventDedupTests()
{
_context = SeekerTestDataFactory.CreateEventsContext();
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private static ManualEvent NewEvent(string hash, bool isResolved) => new()
{
Type = ManualEventType.RecurringDownload,
Message = "m",
Severity = EventSeverity.Warning,
ItemHash = hash,
IsResolved = isResolved,
};
[Fact]
public async Task TwoUnresolvedSameTypeAndHash_ViolatesUniqueIndex_WithSqliteConstraintError()
{
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
// The exception must surface as SQLITE_CONSTRAINT (19) — the exact code PublishManualAsync's
// catch filters on to treat the loser of a race as deduped.
DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => _context.SaveChangesAsync());
SqliteException sqliteEx = ex.InnerException.ShouldBeOfType<SqliteException>();
sqliteEx.SqliteErrorCode.ShouldBe(19);
}
[Fact]
public async Task ResolvedDuplicate_IsExemptFromUniqueIndex()
{
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
await _context.SaveChangesAsync();
// The index is filtered on "is_resolved = 0", so a resolved row with the same type/hash is allowed.
_context.ManualEvents.Add(NewEvent("abc123", isResolved: true));
await Should.NotThrowAsync(() => _context.SaveChangesAsync());
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
}
}
@@ -0,0 +1,390 @@
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Api.Features.Arr.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.Arr;
public class ArrConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly IArrClientFactory _arrClientFactory;
private readonly IArrClient _arrClient;
private readonly ArrConfigController _controller;
public ArrConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
var logger = Substitute.For<ILogger<ArrConfigController>>();
_arrClientFactory = Substitute.For<IArrClientFactory>();
_arrClient = Substitute.For<IArrClient>();
_arrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(_arrClient);
_controller = new ArrConfigController(logger, _dataContext, _eventsContext, _arrClientFactory);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
#region GET configs
[Theory]
[InlineData(InstanceType.Sonarr)]
[InlineData(InstanceType.Radarr)]
[InlineData(InstanceType.Lidarr)]
[InlineData(InstanceType.Readarr)]
[InlineData(InstanceType.Whisparr)]
public async Task GetArrConfig_AllTypes_ReturnOk(InstanceType type)
{
// Act
var result = await DispatchGet(type);
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var dto = ok.Value.ShouldBeOfType<ArrConfigDto>();
dto.Type.ShouldBe(type);
}
[Fact]
public async Task GetSonarrConfig_OrdersInstancesByName()
{
// Arrange
var config = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
_dataContext.ArrInstances.AddRange(
new ArrInstance { Name = "z", Url = new Uri("http://z"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true },
new ArrInstance { Name = "a", Url = new Uri("http://a"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true });
await _dataContext.SaveChangesAsync();
// Act
var result = await _controller.GetSonarrConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var dto = ok.Value.ShouldBeOfType<ArrConfigDto>();
dto.Instances[0].Name.ShouldBe("a");
dto.Instances[1].Name.ShouldBe("z");
}
#endregion
#region PUT configs
[Fact]
public async Task UpdateSonarrConfig_PersistsFailedImportMaxStrikes()
{
// Arrange
var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = 7 };
// Act
var result = await _controller.UpdateSonarrConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.ArrConfigs.AsNoTracking().FirstAsync(c => c.Type == InstanceType.Sonarr);
saved.FailedImportMaxStrikes.ShouldBe((short)7);
}
[Fact]
public async Task UpdateSonarrConfig_DefaultStrikes_PassesThrough()
{
// ArrConfig.Validate is currently a no-op; -1 (the default disabled value) is accepted
var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = -1 };
// Act
var result = await _controller.UpdateSonarrConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
}
#endregion
#region Create instance
[Fact]
public async Task CreateSonarrInstance_PersistsInstanceUnderSonarrConfig()
{
// Arrange
var request = new ArrInstanceRequest
{
Name = "test",
Url = "http://sonarr.test:8989",
ApiKey = "abc",
Version = 4f,
};
// Act
var result = await _controller.CreateSonarrInstance(request);
// Assert
var created = result.ShouldBeOfType<CreatedAtActionResult>();
var dto = created.Value.ShouldBeOfType<ArrInstanceDto>();
dto.Name.ShouldBe("test");
var sonarrConfig = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(c => c.Type == InstanceType.Sonarr);
sonarrConfig.Instances.ShouldContain(i => i.Name == "test");
}
[Fact]
public async Task CreateSonarrInstance_PlaceholderApiKey_ThrowsValidationException()
{
// Arrange — placeholder ApiKey is rejected by ArrInstanceRequest.ToEntity
var request = new ArrInstanceRequest
{
Name = "test",
Url = "http://sonarr.test:8989",
ApiKey = "••••••••",
Version = 4f,
};
// Act / Assert
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _controller.CreateSonarrInstance(request));
}
#endregion
#region Update instance
[Fact]
public async Task UpdateSonarrInstance_UnknownId_ReturnsNotFound()
{
// Arrange
var request = new ArrInstanceRequest
{
Name = "x",
Url = "http://x",
ApiKey = "k",
Version = 4f,
};
// Act
var result = await _controller.UpdateSonarrInstance(Guid.NewGuid(), request);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task UpdateSonarrInstance_Existing_PersistsChanges()
{
// Arrange
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Name = "orig",
Url = new Uri("http://orig:8989"),
ApiKey = "k",
ArrConfigId = sonarr.Id,
Enabled = true,
};
_dataContext.ArrInstances.Add(instance);
await _dataContext.SaveChangesAsync();
var request = new ArrInstanceRequest
{
Name = "renamed",
Url = "http://renamed:8989",
ApiKey = "newkey",
Version = 4f,
Enabled = false,
};
// Act
var result = await _controller.UpdateSonarrInstance(instance.Id, request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.ArrInstances.AsNoTracking().FirstAsync(i => i.Id == instance.Id);
saved.Name.ShouldBe("renamed");
saved.Enabled.ShouldBeFalse();
saved.ApiKey.ShouldBe("newkey");
}
#endregion
#region Delete instance
[Fact]
public async Task DeleteSonarrInstance_UnknownId_ReturnsNotFound()
{
// Act
var result = await _controller.DeleteSonarrInstance(Guid.NewGuid());
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task DeleteSonarrInstance_Existing_ReturnsNoContent()
{
// Arrange
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Name = "doomed",
Url = new Uri("http://doomed:8989"),
ApiKey = "k",
ArrConfigId = sonarr.Id,
Enabled = true,
};
_dataContext.ArrInstances.Add(instance);
await _dataContext.SaveChangesAsync();
// Act
var result = await _controller.DeleteSonarrInstance(instance.Id);
// Assert
result.ShouldBeOfType<NoContentResult>();
(await _dataContext.ArrInstances.CountAsync(i => i.Id == instance.Id)).ShouldBe(0);
}
[Fact]
public async Task DeleteSonarrInstance_WhenStateCleanupFails_KeepsInstance()
{
// Arrange
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
var instance = new ArrInstance
{
Name = "doomed",
Url = new Uri("http://doomed:8989"),
ApiKey = "k",
ArrConfigId = sonarr.Id,
Enabled = true,
};
_dataContext.ArrInstances.Add(instance);
await _dataContext.SaveChangesAsync();
// Force the events cleanup to fail so the instance delete must roll back
await _eventsContext.DisposeAsync();
// Act
await Should.ThrowAsync<Exception>(() => _controller.DeleteSonarrInstance(instance.Id));
// Assert — instance is preserved (never deleted without its events state being removed)
(await _dataContext.ArrInstances.AsNoTracking().CountAsync(i => i.Id == instance.Id)).ShouldBe(1);
}
#endregion
#region Test instance
[Fact]
public async Task TestSonarrInstance_HealthCheckSucceeds_ReturnsOk()
{
// Arrange — IArrClient.HealthCheckAsync returns Task.CompletedTask by default
var request = new TestArrInstanceRequest
{
Url = "http://sonarr.test:8989",
ApiKey = "k",
Version = 4f,
};
// Act
var result = await _controller.TestSonarrInstance(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _arrClient.Received(1).HealthCheckAsync(Arg.Any<ArrInstance>());
}
[Fact]
public async Task TestSonarrInstance_HealthCheckThrows_ReturnsBadRequest()
{
// Arrange
_arrClient.HealthCheckAsync(Arg.Any<ArrInstance>())
.Returns(Task.FromException(new HttpRequestException("unreachable")));
var request = new TestArrInstanceRequest
{
Url = "http://sonarr.test:8989",
ApiKey = "k",
Version = 4f,
};
// Act
var result = await _controller.TestSonarrInstance(request);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
}
[Fact]
public async Task TestSonarrInstance_PlaceholderApiKeyNoInstanceId_ReturnsBadRequest()
{
// Arrange — placeholder API key with no InstanceId means we can't resolve it; ToTestInstance throws
var request = new TestArrInstanceRequest
{
Url = "http://sonarr.test:8989",
ApiKey = "••••••••",
Version = 4f,
};
// Act
var result = await _controller.TestSonarrInstance(request);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
}
[Fact]
public async Task TestSonarrInstance_PlaceholderApiKeyResolvesFromInstanceId_RunsCheck()
{
// Arrange
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
var stored = new ArrInstance
{
Name = "stored",
Url = new Uri("http://stored:8989"),
ApiKey = "stored-key",
ArrConfigId = sonarr.Id,
Enabled = true,
};
_dataContext.ArrInstances.Add(stored);
await _dataContext.SaveChangesAsync();
var request = new TestArrInstanceRequest
{
Url = "http://sonarr.test:8989",
ApiKey = "••••••••",
Version = 4f,
InstanceId = stored.Id,
};
// Act
var result = await _controller.TestSonarrInstance(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _arrClient.Received(1).HealthCheckAsync(Arg.Is<ArrInstance>(i => i.ApiKey == "stored-key"));
}
#endregion
private Task<IActionResult> DispatchGet(InstanceType type) => type switch
{
InstanceType.Sonarr => _controller.GetSonarrConfig(),
InstanceType.Radarr => _controller.GetRadarrConfig(),
InstanceType.Lidarr => _controller.GetLidarrConfig(),
InstanceType.Readarr => _controller.GetReadarrConfig(),
InstanceType.Whisparr => _controller.GetWhisparrConfig(),
_ => throw new ArgumentOutOfRangeException(nameof(type)),
};
}
@@ -0,0 +1,149 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
/// <summary>
/// Integration tests for POST /api/account/feature-views. Verifies that feature "first seen"
/// timestamps are recorded per user, that recording is idempotent, and that the endpoint
/// requires authentication.
/// </summary>
[Collection("Auth Integration Tests")]
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
public class AccountControllerFeatureViewsTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
private static string? _accessToken;
public AccountControllerFeatureViewsTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
if (_accessToken is not null)
{
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
}
[Fact, TestPriority(0)]
public async Task Setup_CreateAccountAndLogin()
{
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
{
username = "featureadmin",
password = "FeaturePassword123!"
});
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
{
username = "featureadmin",
password = "FeaturePassword123!"
});
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
_accessToken.ShouldNotBeNullOrEmpty();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
[Fact, TestPriority(1)]
public async Task RecordFeatureViews_NewIds_RecordsTimestampsAndReturnsMapWithAnchor()
{
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a", "feature-b" }
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
body.ShouldNotBeNull();
body.CreatedAt.ShouldNotBe(default);
body.Views.ShouldContainKey("feature-a");
body.Views.ShouldContainKey("feature-b");
body.Views["feature-a"].Offset.ShouldBe(TimeSpan.Zero);
}
[Fact, TestPriority(2)]
public async Task RecordFeatureViews_DuplicateId_IsIdempotentAndKeepsOriginalTimestamp()
{
var firstResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a" }
});
firstResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var firstBody = await firstResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
var originalTimestamp = firstBody!.Views["feature-a"];
var secondResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a" }
});
secondResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var secondBody = await secondResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
secondBody!.Views["feature-a"].ShouldBe(originalTimestamp);
}
[Fact, TestPriority(3)]
public async Task RecordFeatureViews_WhenUnauthenticated_ReturnsUnauthorized()
{
var unauthClient = _factory.CreateClient();
var response = await unauthClient.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-a" }
});
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact, TestPriority(4)]
public async Task RecordFeatureViews_TooManyIds_ReturnsBadRequest()
{
var tooMany = Enumerable.Range(0, 101).Select(i => $"feature-{i}").ToArray();
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = tooMany
});
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
[Fact, TestPriority(5)]
public async Task RecordFeatureViews_OverLengthId_IsSkipped()
{
var overLengthId = new string('x', 65);
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
{
featureIds = new[] { "feature-ok", overLengthId }
});
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
body.ShouldNotBeNull();
body.Views.ShouldContainKey("feature-ok");
body.Views.ShouldNotContainKey(overLengthId);
}
private sealed record FeatureViewsResponseDto
{
public DateTimeOffset CreatedAt { get; init; }
public Dictionary<string, DateTimeOffset> Views { get; init; } = new();
}
}
@@ -87,7 +87,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
body.GetProperty("detail").GetString().ShouldContain("OIDC is not enabled");
}
[Fact, TestPriority(3)]
@@ -67,9 +67,11 @@ public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.Oid
var response = await _client.PostAsync("/api/auth/oidc/start", null);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
}
[Fact, TestPriority(3)]
@@ -0,0 +1,174 @@
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
using Cleanuparr.Api.Features.BlacklistSync.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.BlacklistSync;
public class BlacklistSyncConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
private readonly BlacklistSyncConfigController _controller;
public BlacklistSyncConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<BlacklistSyncConfigController>>();
_jobManagementService = Substitute.For<IJobManagementService>();
_controller = new BlacklistSyncConfigController(logger, _dataContext, _jobManagementService);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetBlacklistSyncConfig_ReturnsExistingConfig()
{
// Act
var result = await _controller.GetBlacklistSyncConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<BlacklistSyncConfig>();
}
[Fact]
public async Task UpdateBlacklistSyncConfig_BecameEnabled_StartsAndTriggersJob()
{
// Arrange — start disabled, enable with URL path
var request = new UpdateBlacklistSyncConfigRequest
{
Enabled = true,
BlacklistPath = "https://example.com/blacklist.txt",
};
// Act
var result = await _controller.UpdateBlacklistSyncConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StartJob(JobType.BlacklistSynchronizer, null, Arg.Any<string>());
await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer);
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
}
[Fact]
public async Task UpdateBlacklistSyncConfig_BecameDisabled_StopsJob()
{
// Arrange — pre-enable so the toggle to disabled hits the stop path
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
existing.Enabled = true;
existing.BlacklistPath = "https://example.com/blacklist.txt";
await _dataContext.SaveChangesAsync();
var request = new UpdateBlacklistSyncConfigRequest
{
Enabled = false,
BlacklistPath = existing.BlacklistPath,
};
// Act
var result = await _controller.UpdateBlacklistSyncConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StopJob(JobType.BlacklistSynchronizer);
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
}
[Fact]
public async Task UpdateBlacklistSyncConfig_StaysEnabledAndPathChanged_TriggersOnce()
{
// Arrange — pre-enable
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
existing.Enabled = true;
existing.BlacklistPath = "https://example.com/old.txt";
await _dataContext.SaveChangesAsync();
var request = new UpdateBlacklistSyncConfigRequest
{
Enabled = true,
BlacklistPath = "https://example.com/new.txt",
};
// Act
var result = await _controller.UpdateBlacklistSyncConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer);
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
}
[Fact]
public async Task UpdateBlacklistSyncConfig_StaysEnabledNoPathChange_NoJobOps()
{
// Arrange — pre-enable, then resubmit identical values
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
existing.Enabled = true;
existing.BlacklistPath = "https://example.com/list.txt";
await _dataContext.SaveChangesAsync();
var request = new UpdateBlacklistSyncConfigRequest
{
Enabled = true,
BlacklistPath = existing.BlacklistPath,
};
// Act
var result = await _controller.UpdateBlacklistSyncConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
await _jobManagementService.DidNotReceive().TriggerJobOnce(Arg.Any<JobType>());
}
[Fact]
public async Task UpdateBlacklistSyncConfig_EnabledWithMissingPath_Throws()
{
// Arrange
var request = new UpdateBlacklistSyncConfigRequest
{
Enabled = true,
BlacklistPath = null,
};
// Act / Assert — Validate throws and is rethrown
await Should.ThrowAsync<Exception>(() => _controller.UpdateBlacklistSyncConfig(request));
}
[Fact]
public async Task UpdateBlacklistSyncConfig_PersistsChangesToDatabase()
{
// Arrange
var request = new UpdateBlacklistSyncConfigRequest
{
Enabled = true,
BlacklistPath = "https://example.com/list.txt",
};
// Act
await _controller.UpdateBlacklistSyncConfig(request);
// Assert
var saved = await _dataContext.BlacklistSyncConfigs.AsNoTracking().FirstAsync();
saved.Enabled.ShouldBeTrue();
saved.BlacklistPath.ShouldBe("https://example.com/list.txt");
}
}
@@ -0,0 +1,106 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class DeadTorrentConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly DeadTorrentConfigController _controller;
public DeadTorrentConfigControllerTests()
{
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
_controller = new DeadTorrentConfigController(logger, _dataContext);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static DeadTorrentConfigRequest ValidRequest(
bool enabled = true,
string targetCategory = "cleanuparr-dead",
bool useTag = false,
ushort maxStrikes = 3,
List<string>? categories = null)
=> new()
{
Enabled = enabled,
TargetCategory = targetCategory,
UseTag = useTag,
MaxStrikes = maxStrikes,
Categories = categories ?? ["movies"],
};
[Fact]
public async Task Update_ValidRequest_PersistsConfig()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 5, categories: ["movies", "tv"]));
result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.DeadTorrentConfigs.AsNoTracking().SingleAsync(d => d.DownloadClientConfigId == client.Id);
saved.Enabled.ShouldBeTrue();
saved.MaxStrikes.ShouldBe((ushort)5);
saved.Categories.ShouldBe(new List<string> { "movies", "tv" });
}
[Fact]
public async Task Update_ThenGet_RoundTrips()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(useTag: true, maxStrikes: 4));
var result = await _controller.GetDeadTorrentConfig(client.Id);
var ok = result.ShouldBeOfType<OkObjectResult>();
var config = ok.Value.ShouldBeOfType<DeadTorrentConfigResponse>();
config.UseTag.ShouldBeTrue();
config.MaxStrikes.ShouldBe((ushort)4);
}
[Fact]
public async Task Update_StrikesBelowMinimum_ThrowsValidationException()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 2)));
}
[Fact]
public async Task Update_EnabledForRTorrent_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.rTorrent, "Test rTorrent");
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Update_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
}
@@ -0,0 +1,128 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class DownloadCleanerConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
private readonly DownloadCleanerConfigController _controller;
public DownloadCleanerConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<DownloadCleanerConfigController>>();
_jobManagementService = Substitute.For<IJobManagementService>();
_controller = new DownloadCleanerConfigController(logger, _dataContext, _jobManagementService);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetDownloadCleanerConfig_NoClients_ReturnsConfigWithEmptyClientsList()
{
// Act
var result = await _controller.GetDownloadCleanerConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldNotBeNull();
}
[Fact]
public async Task UpdateDownloadCleanerConfig_Enabled_StartsJob()
{
// Arrange
var request = new UpdateDownloadCleanerConfigRequest
{
Enabled = true,
CronExpression = "0 0 * * * ?",
IgnoredDownloads = new List<string>(),
};
// Act
var result = await _controller.UpdateDownloadCleanerConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StartJob(JobType.DownloadCleaner, null, "0 0 * * * ?");
}
[Fact]
public async Task UpdateDownloadCleanerConfig_Disabled_StopsJob()
{
// Arrange — pre-enable
var existing = await _dataContext.DownloadCleanerConfigs.FirstAsync();
existing.Enabled = true;
await _dataContext.SaveChangesAsync();
var request = new UpdateDownloadCleanerConfigRequest
{
Enabled = false,
CronExpression = "0 0 * * * ?",
IgnoredDownloads = new List<string>(),
};
// Act
var result = await _controller.UpdateDownloadCleanerConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StopJob(JobType.DownloadCleaner);
}
[Fact]
public async Task UpdateDownloadCleanerConfig_InvalidCron_PropagatesValidationException()
{
// Arrange — controller's catch only handles System.ComponentModel.DataAnnotations.ValidationException;
// CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException which propagates.
var request = new UpdateDownloadCleanerConfigRequest
{
Enabled = true,
CronExpression = "not-a-cron",
IgnoredDownloads = new List<string>(),
};
// Act / Assert
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _controller.UpdateDownloadCleanerConfig(request));
}
[Fact]
public async Task UpdateDownloadCleanerConfig_PersistsChanges()
{
// Arrange
var request = new UpdateDownloadCleanerConfigRequest
{
Enabled = true,
CronExpression = "0 0/15 * * * ?",
UseAdvancedScheduling = true,
IgnoredDownloads = new List<string> { "skip-me" },
};
// Act
await _controller.UpdateDownloadCleanerConfig(request);
// Assert
var saved = await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync();
saved.Enabled.ShouldBeTrue();
saved.CronExpression.ShouldBe("0 0/15 * * * ?");
saved.UseAdvancedScheduling.ShouldBeTrue();
saved.IgnoredDownloads.ShouldContain("skip-me");
}
}
@@ -0,0 +1,501 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class SeedingRulesControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly SeedingRulesController _controller;
public SeedingRulesControllerTests()
{
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<SeedingRulesController>>();
_controller = new SeedingRulesController(logger, _dataContext);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private static SeedingRuleRequest CreateValidRequest(
string name = "Test Rule",
List<string>? categories = null,
List<string>? trackerPatterns = null,
List<string>? tagsAny = null,
List<string>? tagsAll = null,
int? priority = null,
double maxRatio = 2.0,
double minSeedTime = 0,
double maxSeedTime = -1,
int minSeeders = 0,
double maxInactiveDays = -1,
bool deleteSourceFiles = true)
{
return new SeedingRuleRequest
{
Name = name,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
TagsAny = tagsAny ?? [],
TagsAll = tagsAll ?? [],
Priority = priority,
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
MaxInactiveDays = maxInactiveDays,
DeleteSourceFiles = deleteSourceFiles,
};
}
private static List<SeedingRuleResponse> GetRulesFromOk(IActionResult result)
{
var okResult = result.ShouldBeOfType<OkObjectResult>();
IEnumerable<SeedingRuleResponse> rules = okResult.Value.ShouldBeAssignableTo<IEnumerable<SeedingRuleResponse>>()!;
return rules.ToList();
}
private static T GetCreatedRule<T>(IActionResult result) where T : ISeedingRule
{
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
return createdResult.Value.ShouldBeOfType<T>();
}
// ──────────────────────────────────────────────────────────────────────
// GetSeedingRules
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetSeedingRules_EmptyRules_ReturnsEmptyList()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var result = await _controller.GetSeedingRules(client.Id);
GetRulesFromOk(result).ShouldBeEmpty();
}
[Fact]
public async Task GetSeedingRules_ReturnsRulesOrderedByPriority()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule C", priority: 3);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule A", priority: 1);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule B", priority: 2);
var result = await _controller.GetSeedingRules(client.Id);
List<SeedingRuleResponse> rules = GetRulesFromOk(result);
rules.Count.ShouldBe(3);
rules[0].Name.ShouldBe("Rule A");
rules[1].Name.ShouldBe("Rule B");
rules[2].Name.ShouldBe("Rule C");
}
[Fact]
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
{
var result = await _controller.GetSeedingRules(Guid.NewGuid());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
public async Task GetSeedingRules_QBitClient_ReturnsTagFields()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id,
tagsAny: ["hd", "private"], tagsAll: ["required"]);
var result = await _controller.GetSeedingRules(client.Id);
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
rule.TagsAny.ShouldBe(new List<string> { "hd", "private" });
rule.TagsAll.ShouldBe(new List<string> { "required" });
}
[Fact]
public async Task GetSeedingRules_ReturnsMinSeeders()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, minSeeders: 5);
var result = await _controller.GetSeedingRules(client.Id);
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
rule.MinSeeders.ShouldBe(5);
}
[Fact]
public async Task GetSeedingRules_DelugeClient_ReturnsEmptyTagFields()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Deluge, "Test Deluge");
SeedingRulesTestDataFactory.AddDelugeSeedingRule(_dataContext, client.Id);
var result = await _controller.GetSeedingRules(client.Id);
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
rule.TagsAny.ShouldBeEmpty();
rule.TagsAll.ShouldBeEmpty();
}
// ──────────────────────────────────────────────────────────────────────
// CreateSeedingRule
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task CreateSeedingRule_ValidRequest_ReturnsCreated()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(name: "Movies Rule", categories: ["movies", "films"]);
var result = await _controller.CreateSeedingRule(client.Id, request);
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
createdResult.StatusCode.ShouldBe(201);
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
rule.Name.ShouldBe("Movies Rule");
rule.Categories.ShouldBe(new List<string> { "movies", "films" });
}
[Fact]
public async Task CreateSeedingRule_AutoAssignsPriority_WhenNotProvided()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest();
var result = await _controller.CreateSeedingRule(client.Id, request);
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(1);
}
[Fact]
public async Task CreateSeedingRule_SetsMinSeeders()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(minSeeders: 5);
var result = await _controller.CreateSeedingRule(client.Id, request);
GetCreatedRule<QBitSeedingRule>(result).MinSeeders.ShouldBe(5);
}
[Fact]
public async Task CreateSeedingRule_SetsMaxInactiveDays()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(maxInactiveDays: 30);
var result = await _controller.CreateSeedingRule(client.Id, request);
GetCreatedRule<QBitSeedingRule>(result).MaxInactiveDays.ShouldBe(30);
}
[Fact]
public async Task CreateSeedingRule_AutoAssignsSequentialPriority()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
var request = CreateValidRequest(name: "Second Rule", categories: ["tv"]);
var result = await _controller.CreateSeedingRule(client.Id, request);
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(2);
}
[Fact]
public async Task CreateSeedingRule_DuplicatePriority_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
var request = CreateValidRequest(priority: 1);
var result = await _controller.CreateSeedingRule(client.Id, request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task CreateSeedingRule_NonExistentClient_ReturnsNotFound()
{
var request = CreateValidRequest();
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
public async Task CreateSeedingRule_EmptyCategories_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(categories: []);
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
}
[Fact]
public async Task CreateSeedingRule_SanitizesWhitespaceInLists()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var request = CreateValidRequest(
trackerPatterns: ["", " ", "valid.com", " trimmed.com "]);
var result = await _controller.CreateSeedingRule(client.Id, request);
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
rule.TrackerPatterns.ShouldBe(new List<string> { "valid.com", "trimmed.com" });
}
[Fact]
public async Task CreateSeedingRule_ForTransmission_CreatesTransmissionRule()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext,
DownloadClientTypeName.Transmission, "Test Transmission");
var request = CreateValidRequest(tagsAny: ["tag1"]);
var result = await _controller.CreateSeedingRule(client.Id, request);
GetCreatedRule<TransmissionSeedingRule>(result).TagsAny.ShouldBe(new List<string> { "tag1" });
}
// ──────────────────────────────────────────────────────────────────────
// UpdateSeedingRule
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateSeedingRule_ValidRequest_ReturnsOk()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(name: "Updated Name", categories: ["tv", "anime"]);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.Name.ShouldBe("Updated Name");
updated.Categories.ShouldBe(new List<string> { "tv", "anime" });
}
[Fact]
public async Task UpdateSeedingRule_DoesNotChangePriority()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 5);
var request = CreateValidRequest(priority: 1);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.Priority.ShouldBe(5);
}
[Fact]
public async Task UpdateSeedingRule_UpdatesTagsForTagFilterableClient()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(tagsAny: ["new-tag"], tagsAll: ["must-have"]);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.TagsAny.ShouldBe(new List<string> { "new-tag" });
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
}
[Fact]
public async Task UpdateSeedingRule_UpdatesMinSeeders()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(minSeeders: 5);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.MinSeeders.ShouldBe(5);
}
[Fact]
public async Task UpdateSeedingRule_UpdatesMaxInactiveDays()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var request = CreateValidRequest(maxInactiveDays: 30);
var result = await _controller.UpdateSeedingRule(rule.Id, request);
var okResult = result.ShouldBeOfType<OkObjectResult>();
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
updated.MaxInactiveDays.ShouldBe(30);
}
[Fact]
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
{
var request = CreateValidRequest();
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
public async Task UpdateSeedingRule_ValidationFailure_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
// Both maxRatio and maxSeedTime negative → validation failure
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
}
// ──────────────────────────────────────────────────────────────────────
// ReorderSeedingRules
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ReorderSeedingRules_ValidRequest_ReturnsNoContent()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule2.Id, rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<NoContentResult>();
}
[Fact]
public async Task ReorderSeedingRules_AssignsSequentialPriorities()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var rule3 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "C", priority: 3);
// Reverse order
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
await _controller.ReorderSeedingRules(client.Id, request);
List<SeedingRuleResponse> rules = GetRulesFromOk(await _controller.GetSeedingRules(client.Id));
rules[0].Name.ShouldBe("C");
rules[0].Priority.ShouldBe(1);
rules[1].Name.ShouldBe("B");
rules[1].Priority.ShouldBe(2);
rules[2].Name.ShouldBe("A");
rules[2].Priority.ShouldBe(3);
}
[Fact]
public async Task ReorderSeedingRules_NonExistentClient_ReturnsNotFound()
{
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
public async Task ReorderSeedingRules_DuplicateIds_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task ReorderSeedingRules_WrongCount_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
// Only send 1 of 2 IDs
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task ReorderSeedingRules_UnknownRuleId_ReturnsBadRequest()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
var result = await _controller.ReorderSeedingRules(client.Id, request);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
}
// ──────────────────────────────────────────────────────────────────────
// DeleteSeedingRule
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task DeleteSeedingRule_ExistingRule_ReturnsNoContent()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
var result = await _controller.DeleteSeedingRule(rule.Id);
result.ShouldBeOfType<NoContentResult>();
}
[Fact]
public async Task DeleteSeedingRule_VerifiesRuleRemoved()
{
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
await _controller.DeleteSeedingRule(rule.Id);
GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).ShouldBeEmpty();
}
[Fact]
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
{
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
}
@@ -0,0 +1,211 @@
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.DownloadCleaner.TestHelpers;
/// <summary>
/// Factory for creating SQLite in-memory contexts for SeedingRulesController tests
/// </summary>
public static class SeedingRulesTestDataFactory
{
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;
}
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 = []
});
context.SeekerConfigs.Add(new SeekerConfig
{
Id = Guid.NewGuid(),
SearchEnabled = true,
ProactiveSearchEnabled = false
});
context.SaveChanges();
}
public static DownloadClientConfig AddDownloadClient(
DataContext context,
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
string name = "Test qBittorrent")
{
var config = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = name,
TypeName = typeName,
Type = DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "admin"
};
context.DownloadClients.Add(config);
context.SaveChanges();
return config;
}
public static QBitSeedingRule AddQBitSeedingRule(
DataContext context,
Guid downloadClientId,
string name = "Test Rule",
int priority = 1,
List<string>? categories = null,
List<string>? trackerPatterns = null,
List<string>? tagsAny = null,
List<string>? tagsAll = null,
double maxRatio = 2.0,
double minSeedTime = 0,
double maxSeedTime = -1,
int minSeeders = 0)
{
var rule = new QBitSeedingRule
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClientId,
Name = name,
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
TagsAny = tagsAny ?? [],
TagsAll = tagsAll ?? [],
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = true,
};
context.QBitSeedingRules.Add(rule);
context.SaveChanges();
return rule;
}
public static DelugeSeedingRule AddDelugeSeedingRule(
DataContext context,
Guid downloadClientId,
string name = "Test Rule",
int priority = 1,
List<string>? categories = null,
double maxRatio = 2.0,
double maxSeedTime = -1,
int minSeeders = 0)
{
var rule = new DelugeSeedingRule
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClientId,
Name = name,
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = [],
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = true,
};
context.DelugeSeedingRules.Add(rule);
context.SaveChanges();
return rule;
}
public static TransmissionSeedingRule AddTransmissionSeedingRule(
DataContext context,
Guid downloadClientId,
string name = "Test Rule",
int priority = 1,
List<string>? categories = null,
double maxRatio = 2.0,
double maxSeedTime = -1,
int minSeeders = 0)
{
var rule = new TransmissionSeedingRule
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClientId,
Name = name,
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = [],
TagsAny = [],
TagsAll = [],
PrivacyType = TorrentPrivacyType.Both,
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime,
MinSeeders = minSeeders,
DeleteSourceFiles = true,
};
context.TransmissionSeedingRules.Add(rule);
context.SaveChanges();
return rule;
}
}
@@ -0,0 +1,200 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
public class UnlinkedConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly UnlinkedConfigController _controller;
public UnlinkedConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<UnlinkedConfigController>>();
_controller = new UnlinkedConfigController(logger, _dataContext);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetUnlinkedConfig_ClientNotFound_ReturnsNotFound()
{
// Act
var result = await _controller.GetUnlinkedConfig(Guid.NewGuid());
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task GetUnlinkedConfig_ClientWithoutConfig_ReturnsOkWithNull()
{
// Arrange — add a client but no UnlinkedConfig
var client = AddDownloadClient();
// Act
var result = await _controller.GetUnlinkedConfig(client.Id);
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeNull();
}
[Fact]
public async Task GetUnlinkedConfig_ClientWithConfig_ReturnsConfig()
{
// Arrange
var client = AddDownloadClient();
_dataContext.UnlinkedConfigs.Add(new UnlinkedConfig
{
DownloadClientConfigId = client.Id,
Enabled = true,
TargetCategory = "unlinked-cat",
Categories = new List<string> { "regular" },
});
await _dataContext.SaveChangesAsync();
// Act
var result = await _controller.GetUnlinkedConfig(client.Id);
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var config = ok.Value.ShouldBeOfType<UnlinkedConfigResponse>();
config.Enabled.ShouldBeTrue();
config.TargetCategory.ShouldBe("unlinked-cat");
}
[Fact]
public async Task UpdateUnlinkedConfig_ClientNotFound_ReturnsNotFound()
{
// Arrange
var dto = new UnlinkedConfigRequest { Enabled = false };
// Act
var result = await _controller.UpdateUnlinkedConfig(Guid.NewGuid(), dto);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task UpdateUnlinkedConfig_NewConfig_CreatesAndReturnsIt()
{
// Arrange
var client = AddDownloadClient();
var dto = new UnlinkedConfigRequest
{
Enabled = false,
TargetCategory = "unlinked-cat",
Categories = new List<string> { "movies" },
};
// Act
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.UnlinkedConfigs.AsNoTracking()
.FirstAsync(u => u.DownloadClientConfigId == client.Id);
saved.TargetCategory.ShouldBe("unlinked-cat");
saved.Categories.ShouldContain("movies");
}
[Fact]
public async Task UpdateUnlinkedConfig_ExistingConfig_UpdatesInPlace()
{
// Arrange
var client = AddDownloadClient();
_dataContext.UnlinkedConfigs.Add(new UnlinkedConfig
{
DownloadClientConfigId = client.Id,
Enabled = false,
TargetCategory = "old-cat",
});
await _dataContext.SaveChangesAsync();
var dto = new UnlinkedConfigRequest
{
Enabled = false,
TargetCategory = "new-cat",
};
// Act
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
// Assert
result.ShouldBeOfType<OkObjectResult>();
var rows = await _dataContext.UnlinkedConfigs
.Where(u => u.DownloadClientConfigId == client.Id)
.ToListAsync();
rows.Count.ShouldBe(1);
rows[0].TargetCategory.ShouldBe("new-cat");
}
[Fact]
public async Task UpdateUnlinkedConfig_EnabledButNoCategories_ReturnsBadRequest()
{
// Arrange — enabled requires at least one category per Validate()
var client = AddDownloadClient();
var dto = new UnlinkedConfigRequest
{
Enabled = true,
TargetCategory = "unlinked-cat",
Categories = new List<string>(),
};
// Act + Assert
await Should.ThrowAsync<ValidationException>(
() => _controller.UpdateUnlinkedConfig(client.Id, dto));
}
[Fact]
public async Task UpdateUnlinkedConfig_TargetInCategories_ReturnsBadRequest()
{
// Arrange — TargetCategory must not appear in Categories
var client = AddDownloadClient();
var dto = new UnlinkedConfigRequest
{
Enabled = true,
TargetCategory = "unlinked-cat",
Categories = new List<string> { "movies", "unlinked-cat" },
};
// Act + Assert
await Should.ThrowAsync<ValidationException>(
() => _controller.UpdateUnlinkedConfig(client.Id, dto));
}
private DownloadClientConfig AddDownloadClient()
{
var client = new DownloadClientConfig
{
Id = Guid.NewGuid(),
Name = "test-client",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
};
_dataContext.DownloadClients.Add(client);
_dataContext.SaveChanges();
return client;
}
}
@@ -0,0 +1,275 @@
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadClient.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.DownloadClient;
public class DownloadClientControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
private readonly IDownloadServiceFactory _downloadServiceFactory;
private readonly DownloadClientController _controller;
public DownloadClientControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<DownloadClientController>>();
_dynamicHttpClientFactory = Substitute.For<IDynamicHttpClientFactory>();
_downloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
_controller = new DownloadClientController(logger, _dataContext, _dynamicHttpClientFactory, _downloadServiceFactory);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetDownloadClientConfig_EmptyDatabase_ReturnsOkWithEmptyClients()
{
// Act
var result = await _controller.GetDownloadClientConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldNotBeNull();
}
[Fact]
public async Task GetDownloadClientConfig_OrdersByTypeThenName()
{
// Arrange — add 3 clients out of order
_dataContext.DownloadClients.AddRange(
NewClient("z-client", DownloadClientTypeName.qBittorrent),
NewClient("a-client", DownloadClientTypeName.qBittorrent),
NewClient("b-client", DownloadClientTypeName.Deluge)
);
await _dataContext.SaveChangesAsync();
// Act
var result = await _controller.GetDownloadClientConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var dict = ok.Value!.GetType().GetProperty("clients")!.GetValue(ok.Value) as List<DownloadClientConfig>;
dict.ShouldNotBeNull();
dict!.Count.ShouldBe(3);
// qBittorrent (0) comes before Deluge (1) by enum value, then alphabetical within type
dict![0].Name.ShouldBe("a-client");
dict![1].Name.ShouldBe("z-client");
dict![2].Name.ShouldBe("b-client");
}
[Fact]
public async Task CreateDownloadClientConfig_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new CreateDownloadClientRequest
{
Enabled = true,
Name = "my-client",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://localhost:8080",
Username = "user",
Password = "pass",
};
// Act
var result = await _controller.CreateDownloadClientConfig(request);
// Assert
var created = result.ShouldBeOfType<CreatedAtActionResult>();
var entity = created.Value.ShouldBeOfType<DownloadClientConfig>();
entity.Name.ShouldBe("my-client");
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task CreateDownloadClientConfig_InvalidHost_PropagatesValidationException()
{
// Arrange
var request = new CreateDownloadClientRequest
{
Name = "x",
Host = string.Empty,
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
};
// Act / Assert — Validate throws, controller's generic catch logs and re-throws
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _controller.CreateDownloadClientConfig(request));
}
[Fact]
public async Task UpdateDownloadClientConfig_UnknownId_ReturnsNotFound()
{
// Arrange
var request = new UpdateDownloadClientRequest
{
Name = "x",
Host = "http://localhost:8080",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
};
// Act
var result = await _controller.UpdateDownloadClientConfig(Guid.NewGuid(), request);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task UpdateDownloadClientConfig_ExistingClient_PersistsChanges()
{
// Arrange
var client = NewClient("orig", DownloadClientTypeName.qBittorrent);
_dataContext.DownloadClients.Add(client);
await _dataContext.SaveChangesAsync();
var request = new UpdateDownloadClientRequest
{
Enabled = true,
Name = "renamed",
Host = "http://newhost:9090",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
};
// Act
var result = await _controller.UpdateDownloadClientConfig(client.Id, request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id);
saved.Name.ShouldBe("renamed");
saved.Host!.ToString().ShouldContain("newhost");
}
[Fact]
public async Task DeleteDownloadClientConfig_ExistingClient_RemovesAndUnregistersHttpClient()
{
// Arrange
var client = NewClient("doomed", DownloadClientTypeName.qBittorrent);
_dataContext.DownloadClients.Add(client);
await _dataContext.SaveChangesAsync();
// Act
var result = await _controller.DeleteDownloadClientConfig(client.Id);
// Assert
result.ShouldBeOfType<NoContentResult>();
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(0);
_dynamicHttpClientFactory.Received(1).UnregisterConfiguration($"DownloadClient_{client.Id}");
}
[Fact]
public async Task DeleteDownloadClientConfig_UnknownId_ReturnsNotFound()
{
// Act
var result = await _controller.DeleteDownloadClientConfig(Guid.NewGuid());
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
_dynamicHttpClientFactory.DidNotReceive().UnregisterConfiguration(Arg.Any<string>());
}
[Fact]
public async Task TestDownloadClient_Healthy_ReturnsOkWithResponseTime()
{
// Arrange
var downloadService = Substitute.For<IDownloadService>();
downloadService.HealthCheckAsync().Returns(new HealthCheckResult
{
IsHealthy = true,
ResponseTime = TimeSpan.FromMilliseconds(123),
});
_downloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>()).Returns(downloadService);
var request = new TestDownloadClientRequest
{
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://localhost:8080",
Password = "pass",
};
// Act
var result = await _controller.TestDownloadClient(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
}
[Fact]
public async Task TestDownloadClient_Unhealthy_ReturnsBadRequestWithMessage()
{
// Arrange
var downloadService = Substitute.For<IDownloadService>();
downloadService.HealthCheckAsync().Returns(new HealthCheckResult
{
IsHealthy = false,
ErrorMessage = "connection refused",
});
_downloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>()).Returns(downloadService);
var request = new TestDownloadClientRequest
{
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = "http://localhost:8080",
Password = "pass",
};
// Act
var result = await _controller.TestDownloadClient(request);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
}
[Fact]
public async Task TestDownloadClient_InvalidHost_ReturnsBadRequest()
{
// Arrange — empty host fails Validate; the controller wraps the exception in BadRequest
var request = new TestDownloadClientRequest
{
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = string.Empty,
Password = "pass",
};
// Act
var result = await _controller.TestDownloadClient(request);
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
}
private static DownloadClientConfig NewClient(string name, DownloadClientTypeName typeName) => new()
{
Id = Guid.NewGuid(),
Name = name,
TypeName = typeName,
Type = DownloadClientType.Torrent,
Host = new Uri("http://localhost:8080"),
};
}
@@ -0,0 +1,172 @@
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Api.Features.General.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.General;
public class GeneralConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
private readonly GeneralConfigController _controller;
public GeneralConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
_dynamicHttpClientFactory = Substitute.For<IDynamicHttpClientFactory>();
var logger = Substitute.For<ILogger<GeneralConfigController>>();
_controller = new GeneralConfigController(logger, _dataContext);
// Mount a DefaultHttpContext with a ServiceProvider that resolves IDynamicHttpClientFactory
var services = new ServiceCollection();
services.AddSingleton(_dynamicHttpClientFactory);
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() },
};
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetGeneralConfig_ReturnsExistingConfig()
{
// Act
var result = await _controller.GetGeneralConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<GeneralConfig>();
}
[Fact]
public async Task UpdateGeneralConfig_PersistsChangesAndUpdatesHttpClients()
{
// Arrange — keep Log defaults matching DB so loggingChanged=false (avoid LoggingConfigManager statics)
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
var request = new UpdateGeneralConfigRequest
{
DisplaySupportBanner = false,
DryRun = false,
HttpMaxRetries = 5,
HttpTimeout = 60,
StatusCheckEnabled = false,
EncryptionKey = existing.EncryptionKey,
IgnoredDownloads = new List<string> { "ignored-item" },
StrikeInactivityWindowHours = 48,
Log = MatchingLogRequest(existing.Log),
Auth = new UpdateAuthConfigRequest(),
};
// Act
var result = await _controller.UpdateGeneralConfig(request, _eventsContext);
// Assert
result.ShouldBeOfType<OkObjectResult>();
_dynamicHttpClientFactory.Received(1).UpdateAllClientsFromGeneralConfig(Arg.Any<GeneralConfig>());
var saved = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
saved.DisplaySupportBanner.ShouldBeFalse();
saved.HttpMaxRetries.ShouldBe((ushort)5);
saved.HttpTimeout.ShouldBe((ushort)60);
saved.StrikeInactivityWindowHours.ShouldBe((ushort)48);
saved.IgnoredDownloads.ShouldContain("ignored-item");
}
[Fact]
public async Task UpdateGeneralConfig_InvalidHttpTimeout_Throws()
{
// Arrange — HttpTimeout=0 fails validation
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
var request = new UpdateGeneralConfigRequest
{
HttpTimeout = 0,
EncryptionKey = existing.EncryptionKey,
StrikeInactivityWindowHours = 24,
Log = MatchingLogRequest(existing.Log),
Auth = new UpdateAuthConfigRequest(),
};
// Act / Assert
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
}
[Fact]
public async Task UpdateGeneralConfig_InvalidStrikeWindow_Throws()
{
// Arrange — StrikeInactivityWindowHours > 168 fails validation
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
var request = new UpdateGeneralConfigRequest
{
HttpTimeout = 60,
EncryptionKey = existing.EncryptionKey,
StrikeInactivityWindowHours = 200,
Log = MatchingLogRequest(existing.Log),
Auth = new UpdateAuthConfigRequest(),
};
// Act / Assert
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
}
[Fact]
public async Task UpdateGeneralConfig_ConnectivityCheckEnabledWithNoUrls_Throws()
{
// Arrange — connectivity check enabled but no URLs fails validation
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
var request = new UpdateGeneralConfigRequest
{
HttpTimeout = 60,
EncryptionKey = existing.EncryptionKey,
StrikeInactivityWindowHours = 24,
ConnectivityCheckEnabled = true,
ConnectivityCheckUrls = new List<string>(),
Log = MatchingLogRequest(existing.Log),
Auth = new UpdateAuthConfigRequest(),
};
// Act / Assert
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
}
[Fact]
public async Task PurgeAllStrikes_ReturnsDeletedCounts()
{
// Act
var result = await _controller.PurgeAllStrikes(_eventsContext);
// Assert — initially empty, but the endpoint still succeeds with zero counts
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldNotBeNull();
}
private static UpdateLoggingConfigRequest MatchingLogRequest(LoggingConfig existing) => new()
{
Level = existing.Level,
RollingSizeMB = existing.RollingSizeMB,
RetainedFileCount = existing.RetainedFileCount,
TimeLimitHours = existing.TimeLimitHours,
ArchiveEnabled = existing.ArchiveEnabled,
ArchiveRetainedCount = existing.ArchiveRetainedCount,
ArchiveTimeLimitHours = existing.ArchiveTimeLimitHours,
};
}
@@ -0,0 +1,151 @@
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
using Cleanuparr.Api.Features.MalwareBlocker.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.MalwareBlocker;
public class MalwareBlockerConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
private readonly MalwareBlockerConfigController _controller;
public MalwareBlockerConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<MalwareBlockerConfigController>>();
_jobManagementService = Substitute.For<IJobManagementService>();
_controller = new MalwareBlockerConfigController(logger, _dataContext, _jobManagementService);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetMalwareBlockerConfig_ReturnsExistingConfig()
{
// Act
var result = await _controller.GetMalwareBlockerConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<ContentBlockerConfig>();
}
[Fact]
public async Task UpdateMalwareBlockerConfig_Enabled_StartsJobWithCron()
{
// Arrange — Enabled requires at least one blocklist with a valid path
var request = new UpdateMalwareBlockerConfigRequest
{
Enabled = true,
CronExpression = "0/5 * * * * ?",
Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/blocklist.txt" },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false },
};
// Act
var result = await _controller.UpdateMalwareBlockerConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StartJob(JobType.MalwareBlocker, null, "0/5 * * * * ?");
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
}
[Fact]
public async Task UpdateMalwareBlockerConfig_Disabled_StopsJob()
{
// Arrange — pre-enable
var existing = await _dataContext.ContentBlockerConfigs.FirstAsync();
existing.Enabled = true;
await _dataContext.SaveChangesAsync();
var request = new UpdateMalwareBlockerConfigRequest
{
Enabled = false,
CronExpression = "0/5 * * * * ?",
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 },
};
// Act
var result = await _controller.UpdateMalwareBlockerConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StopJob(JobType.MalwareBlocker);
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
}
[Fact]
public async Task UpdateMalwareBlockerConfig_InvalidCronExpression_PropagatesValidationException()
{
// Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException,
// which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match.
var request = new UpdateMalwareBlockerConfigRequest
{
Enabled = true,
CronExpression = "definitely-not-a-cron",
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 },
};
// Act / Assert
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _controller.UpdateMalwareBlockerConfig(request));
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
}
[Fact]
public async Task UpdateMalwareBlockerConfig_PersistsChanges()
{
// Arrange
var request = new UpdateMalwareBlockerConfigRequest
{
Enabled = true,
CronExpression = "0/10 * * * * ?",
IgnorePrivate = true,
DeletePrivate = false,
Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/list.txt" },
Radarr = new BlocklistSettings { Enabled = false },
Lidarr = new BlocklistSettings { Enabled = false },
Readarr = new BlocklistSettings { Enabled = false },
Whisparr = new BlocklistSettings { Enabled = false },
IgnoredDownloads = new List<string> { "foo" },
};
// Act
await _controller.UpdateMalwareBlockerConfig(request);
// Assert
var saved = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
saved.Enabled.ShouldBeTrue();
saved.CronExpression.ShouldBe("0/10 * * * * ?");
saved.IgnorePrivate.ShouldBeTrue();
saved.Sonarr.Enabled.ShouldBeTrue();
saved.IgnoredDownloads.ShouldContain("foo");
}
}
@@ -0,0 +1,156 @@
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.QueueCleaner.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.QueueCleaner;
public class QueueCleanerConfigControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
private readonly QueueCleanerConfigController _controller;
public QueueCleanerConfigControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<QueueCleanerConfigController>>();
_jobManagementService = Substitute.For<IJobManagementService>();
_controller = new QueueCleanerConfigController(logger, _dataContext, _jobManagementService);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task GetQueueCleanerConfig_ReturnsExistingConfig()
{
// Act
var result = await _controller.GetQueueCleanerConfig();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<QueueCleanerConfig>();
}
[Fact]
public async Task UpdateQueueCleanerConfig_Enabled_StartsJob()
{
// Arrange
var request = new UpdateQueueCleanerConfigRequest
{
Enabled = true,
CronExpression = "0 0/5 * * * ?",
FailedImport = new FailedImportConfig(),
IgnoredDownloads = new List<string>(),
};
// Act
var result = await _controller.UpdateQueueCleanerConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StartJob(JobType.QueueCleaner, null, "0 0/5 * * * ?");
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
}
[Fact]
public async Task UpdateQueueCleanerConfig_Disabled_StopsJob()
{
// Arrange — pre-enable
var existing = await _dataContext.QueueCleanerConfigs.FirstAsync();
existing.Enabled = true;
await _dataContext.SaveChangesAsync();
var request = new UpdateQueueCleanerConfigRequest
{
Enabled = false,
CronExpression = "0 0/5 * * * ?",
FailedImport = new FailedImportConfig(),
IgnoredDownloads = new List<string>(),
};
// Act
var result = await _controller.UpdateQueueCleanerConfig(request);
// Assert
result.ShouldBeOfType<OkObjectResult>();
await _jobManagementService.Received(1).StopJob(JobType.QueueCleaner);
}
[Fact]
public async Task UpdateQueueCleanerConfig_InvalidCronExpression_PropagatesValidationException()
{
// Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException,
// which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match.
var request = new UpdateQueueCleanerConfigRequest
{
Enabled = true,
CronExpression = "not-a-cron",
FailedImport = new FailedImportConfig(),
IgnoredDownloads = new List<string>(),
};
// Act / Assert
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
() => _controller.UpdateQueueCleanerConfig(request));
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
}
[Fact]
public async Task UpdateQueueCleanerConfig_ConfigValidationFails_ReturnsBadRequest()
{
// Arrange — DownloadingMetadataMaxStrikes < 3 (and > 0) triggers Validate() exception
var request = new UpdateQueueCleanerConfigRequest
{
Enabled = true,
CronExpression = "0 0/5 * * * ?",
FailedImport = new FailedImportConfig(),
DownloadingMetadataMaxStrikes = 2,
IgnoredDownloads = new List<string>(),
};
// Act + Assert
await Should.ThrowAsync<System.ComponentModel.DataAnnotations.ValidationException>(
() => _controller.UpdateQueueCleanerConfig(request));
}
[Fact]
public async Task UpdateQueueCleanerConfig_PersistsChanges()
{
// Arrange
var request = new UpdateQueueCleanerConfigRequest
{
Enabled = true,
CronExpression = "0 0/10 * * * ?",
FailedImport = new FailedImportConfig(),
DownloadingMetadataMaxStrikes = 5,
ProcessNoContentId = true,
IgnoredDownloads = new List<string> { "ignored" },
};
// Act
await _controller.UpdateQueueCleanerConfig(request);
// Assert
var saved = await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync();
saved.Enabled.ShouldBeTrue();
saved.CronExpression.ShouldBe("0 0/10 * * * ?");
saved.DownloadingMetadataMaxStrikes.ShouldBe((ushort)5);
saved.ProcessNoContentId.ShouldBeTrue();
saved.IgnoredDownloads.ShouldContain("ignored");
}
}
@@ -0,0 +1,258 @@
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.QueueCleaner.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Features.QueueCleaner;
public class QueueRulesControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IRuleIntervalValidator _validator;
private readonly QueueRulesController _controller;
public QueueRulesControllerTests()
{
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
var logger = Substitute.For<ILogger<QueueRulesController>>();
_validator = Substitute.For<IRuleIntervalValidator>();
_validator.ValidateStallRuleIntervals(Arg.Any<StallRule>(), Arg.Any<List<StallRule>>()).Returns(ValidationResult.Success());
_validator.ValidateSlowRuleIntervals(Arg.Any<SlowRule>(), Arg.Any<List<SlowRule>>()).Returns(ValidationResult.Success());
_controller = new QueueRulesController(logger, _dataContext, _validator);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
#region Stall Rules
[Fact]
public async Task GetStallRules_EmptyDatabase_ReturnsEmptyList()
{
// Act
var result = await _controller.GetStallRules();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var rules = ok.Value.ShouldBeOfType<List<StallRule>>();
rules.ShouldBeEmpty();
}
[Fact]
public async Task CreateStallRule_NewName_ReturnsCreatedWithRule()
{
// Arrange
var dto = NewStallDto(name: "default");
// Act
var result = await _controller.CreateStallRule(dto);
// Assert
var created = result.ShouldBeOfType<CreatedAtActionResult>();
var rule = created.Value.ShouldBeOfType<StallRule>();
rule.Name.ShouldBe("default");
rule.Id.ShouldNotBe(Guid.Empty);
(await _dataContext.StallRules.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task CreateStallRule_DuplicateName_ReturnsBadRequest()
{
// Arrange — seed existing rule
await _controller.CreateStallRule(NewStallDto(name: "rule-a"));
// Act — try to create another with same name (case-insensitive)
var result = await _controller.CreateStallRule(NewStallDto(name: "RULE-A"));
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
(await _dataContext.StallRules.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task CreateStallRule_IntervalValidatorFails_ReturnsBadRequest()
{
// Arrange
_validator.ValidateStallRuleIntervals(Arg.Any<StallRule>(), Arg.Any<List<StallRule>>())
.Returns(ValidationResult.Failure("overlaps with existing rule"));
// Act
var result = await _controller.CreateStallRule(NewStallDto(name: "x"));
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
(await _dataContext.StallRules.CountAsync()).ShouldBe(0);
}
[Fact]
public async Task UpdateStallRule_NonExistentId_ReturnsNotFound()
{
// Act
var result = await _controller.UpdateStallRule(Guid.NewGuid(), NewStallDto("x"));
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task UpdateStallRule_ExistingRule_PersistsChanges()
{
// Arrange
var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "orig"));
var id = ((StallRule)create.Value!).Id;
// Act
var result = await _controller.UpdateStallRule(id, NewStallDto(name: "renamed"));
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var rule = ok.Value.ShouldBeOfType<StallRule>();
rule.Name.ShouldBe("renamed");
var saved = await _dataContext.StallRules.AsNoTracking().FirstAsync(r => r.Id == id);
saved.Name.ShouldBe("renamed");
}
[Fact]
public async Task UpdateStallRule_DuplicateNameOtherRule_ReturnsBadRequest()
{
// Arrange
await _controller.CreateStallRule(NewStallDto(name: "alpha"));
var betaCreate = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "beta"));
var betaId = ((StallRule)betaCreate.Value!).Id;
// Act — try to rename beta → alpha
var result = await _controller.UpdateStallRule(betaId, NewStallDto(name: "alpha"));
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
}
[Fact]
public async Task DeleteStallRule_ExistingRule_ReturnsNoContent()
{
// Arrange
var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "doomed"));
var id = ((StallRule)create.Value!).Id;
// Act
var result = await _controller.DeleteStallRule(id);
// Assert
result.ShouldBeOfType<NoContentResult>();
(await _dataContext.StallRules.CountAsync()).ShouldBe(0);
}
[Fact]
public async Task DeleteStallRule_NonExistent_ReturnsNotFound()
{
// Act
var result = await _controller.DeleteStallRule(Guid.NewGuid());
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
#endregion
#region Slow Rules
[Fact]
public async Task GetSlowRules_EmptyDatabase_ReturnsEmptyList()
{
// Act
var result = await _controller.GetSlowRules();
// Assert
var ok = result.ShouldBeOfType<OkObjectResult>();
var rules = ok.Value.ShouldBeOfType<List<SlowRule>>();
rules.ShouldBeEmpty();
}
[Fact]
public async Task CreateSlowRule_NewName_ReturnsCreated()
{
// Arrange
var dto = NewSlowDto(name: "slow-default");
// Act
var result = await _controller.CreateSlowRule(dto);
// Assert
var created = result.ShouldBeOfType<CreatedAtActionResult>();
var rule = created.Value.ShouldBeOfType<SlowRule>();
rule.Name.ShouldBe("slow-default");
(await _dataContext.SlowRules.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task CreateSlowRule_DuplicateName_ReturnsBadRequest()
{
// Arrange
await _controller.CreateSlowRule(NewSlowDto(name: "x"));
// Act
var result = await _controller.CreateSlowRule(NewSlowDto(name: "X"));
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
}
[Fact]
public async Task UpdateSlowRule_NonExistentId_ReturnsNotFound()
{
// Act
var result = await _controller.UpdateSlowRule(Guid.NewGuid(), NewSlowDto("x"));
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
[Fact]
public async Task DeleteSlowRule_NonExistent_ReturnsNotFound()
{
// Act
var result = await _controller.DeleteSlowRule(Guid.NewGuid());
// Assert
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
}
#endregion
private static StallRuleDto NewStallDto(string name) => new()
{
Name = name,
Enabled = true,
MaxStrikes = 3,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = true,
};
private static SlowRuleDto NewSlowDto(string name) => new()
{
Name = name,
Enabled = true,
MaxStrikes = 3,
PrivacyType = TorrentPrivacyType.Public,
MinCompletionPercentage = 0,
MaxCompletionPercentage = 100,
ResetStrikesOnProgress = true,
MinSpeed = "1MB",
};
}
@@ -5,6 +5,7 @@ using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Persistence.Providers;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
@@ -13,17 +14,20 @@ namespace Cleanuparr.Api.Tests.Features.Seeker;
public class CustomFormatScoreControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly CustomFormatScoreController _controller;
public CustomFormatScoreControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_controller = new CustomFormatScoreController(_dataContext);
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
_controller = new CustomFormatScoreController(_dataContext, _eventsContext, new SqliteDatabaseProvider());
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
@@ -59,7 +63,7 @@ public class CustomFormatScoreControllerTests : IDisposable
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
var body = GetResponseBody(result);
body.GetProperty("PageSize").GetInt32().ShouldBe(100);
body.GetProperty("PageSize").GetInt32().ShouldBe(500);
}
[Fact]
@@ -70,7 +74,7 @@ public class CustomFormatScoreControllerTests : IDisposable
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 result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Below);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
@@ -85,7 +89,7 @@ public class CustomFormatScoreControllerTests : IDisposable
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
var result = await _controller.GetCustomFormatScores(hideUnmonitored: true);
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Monitored);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
@@ -118,7 +122,7 @@ public class CustomFormatScoreControllerTests : IDisposable
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
lastSynced: DateTime.UtcNow.AddHours(-1));
var result = await _controller.GetCustomFormatScores(sortBy: "date");
var result = await _controller.GetCustomFormatScores(sortBy: CfScoresSortBy.LastSyncedAt);
var body = GetResponseBody(result);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
@@ -158,6 +162,94 @@ public class CustomFormatScoreControllerTests : IDisposable
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
}
[Fact]
public async Task GetCustomFormatScores_WithCutoffFilterMet_ExcludesBelowCutoff()
{
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.GetCustomFormatScores(cutoffFilter: CutoffFilter.Met);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithCutoffFilterAll_IncludesEverything()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Above", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.All);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithMonitoredFilterUnmonitored_ReturnsOnlyUnmonitored()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500, isMonitored: true);
AddScoreEntry(radarr.Id, 2, "B", currentScore: 100, cutoffScore: 500, isMonitored: false);
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Unmonitored);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("B");
}
[Fact]
public async Task GetCustomFormatScores_WithQualityProfileFilter_ReturnsOnlyMatchingProfile()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "HD Movie", currentScore: 100, cutoffScore: 500, qualityProfileName: "HD");
AddScoreEntry(radarr.Id, 2, "UHD Movie", currentScore: 200, cutoffScore: 500, qualityProfileName: "UHD");
var result = await _controller.GetCustomFormatScores(qualityProfile: "UHD");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("UHD Movie");
}
[Fact]
public async Task GetCustomFormatScores_WithExplicitSortDirectionAsc_OverridesDefault()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "B", currentScore: 300, cutoffScore: 500);
// CurrentScore default is descending; overriding with Asc should flip it.
var result = await _controller.GetCustomFormatScores(
sortBy: CfScoresSortBy.CurrentScore,
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("CurrentScore").GetInt32().ShouldBe(100);
items[1].GetProperty("CurrentScore").GetInt32().ShouldBe(300);
}
[Fact]
public async Task GetCustomFormatScores_WithSortByTitleDescending_OrdersReverseAlphabetically()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Apple", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Banana", currentScore: 200, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(
sortBy: CfScoresSortBy.Title,
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Desc);
var body = GetResponseBody(result);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Banana");
}
#endregion
#region GetRecentUpgrades Tests
@@ -245,6 +337,77 @@ public class CustomFormatScoreControllerTests : IDisposable
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
}
[Fact]
public async Task GetRecentUpgrades_WithUpgradeCrossingWindowBoundary_IsDetected()
{
// CR2: pre-window baseline must still participate so the first in-window
// row can be recognised as an upgrade.
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-10));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-3));
var result = await _controller.GetRecentUpgrades(days: 7);
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(200);
}
[Fact]
public async Task GetRecentUpgrades_WithSortByScoreDeltaDescending_OrdersByLargestDelta()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// Item 1: +50
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-2));
// Item 2: +400
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 500, recordedAt: DateTime.UtcNow.AddDays(-2));
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.ScoreDelta);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("NewScore").GetInt32().ShouldBe(500);
items[1].GetProperty("NewScore").GetInt32().ShouldBe(150);
}
[Fact]
public async Task GetRecentUpgrades_WithSortByTitleAscending_OrdersAlphabetically()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.Title);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("Title").GetString().ShouldBe("Item 1");
items[1].GetProperty("Title").GetString().ShouldBe("Item 2");
}
[Fact]
public async Task GetRecentUpgrades_WithSearchFilter_ReturnsMatchingTitlesOnly()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// AddHistoryEntry titles as "Item {externalItemId}".
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
var result = await _controller.GetRecentUpgrades(search: "42");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Item 42");
}
[Fact]
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
{
@@ -333,9 +496,10 @@ public class CustomFormatScoreControllerTests : IDisposable
int cutoffScore,
InstanceType itemType = InstanceType.Radarr,
DateTime? lastSynced = null,
bool isMonitored = true)
bool isMonitored = true,
string qualityProfileName = "HD")
{
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = arrInstanceId,
ExternalItemId = externalItemId,
@@ -345,11 +509,11 @@ public class CustomFormatScoreControllerTests : IDisposable
FileId = externalItemId * 10,
CurrentScore = currentScore,
CutoffScore = cutoffScore,
QualityProfileName = "HD",
QualityProfileName = qualityProfileName,
IsMonitored = isMonitored,
LastSyncedAt = lastSynced ?? DateTime.UtcNow
});
_dataContext.SaveChanges();
_eventsContext.SaveChanges();
}
private void AddHistoryEntry(
@@ -361,7 +525,7 @@ public class CustomFormatScoreControllerTests : IDisposable
int cutoffScore = 500,
InstanceType itemType = InstanceType.Radarr)
{
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
_eventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
{
ArrInstanceId = arrInstanceId,
ExternalItemId = externalItemId,
@@ -372,7 +536,7 @@ public class CustomFormatScoreControllerTests : IDisposable
CutoffScore = cutoffScore,
RecordedAt = recordedAt
});
_dataContext.SaveChanges();
_eventsContext.SaveChanges();
}
#endregion
@@ -36,90 +36,58 @@ public class SearchStatsControllerTests : IDisposable
return JsonDocument.Parse(json).RootElement;
}
#region ParseEventData (tested via GetEvents)
#region GetEvents with SearchEventData
[Fact]
public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults()
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
{
AddSearchEvent(data: null);
AddSearchEvent();
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);
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
}
[Fact]
public async Task GetEvents_WithValidFullJson_ParsesAllFields()
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
{
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 radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddSearchEvent(
arrInstanceId: radarr.Id,
itemTitle: "Movie A",
searchType: SeekerSearchType.Proactive,
searchReason: SeekerSearchReason.Missing,
grabbedItems: ["Movie A (2024)"]);
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("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
}
[Fact]
public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields()
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
{
// 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);
AddSearchEvent(
itemTitle: "Series A",
searchType: SeekerSearchType.Replacement,
searchReason: SeekerSearchReason.Replacement);
var result = await _controller.GetEvents();
var body = GetResponseBody(result);
var item = body.GetProperty("Items")[0];
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
}
#endregion
@@ -127,23 +95,19 @@ public class SearchStatsControllerTests : IDisposable
#region GetEvents Filtering
[Fact]
public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl()
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
{
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" }));
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
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");
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
}
[Fact]
@@ -152,21 +116,21 @@ public class SearchStatsControllerTests : IDisposable
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" }));
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
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");
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
}
[Fact]
public async Task GetEvents_WithSearchFilter_FiltersOnDataField()
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
{
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } }));
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } }));
AddSearchEvent(itemTitle: "The Matrix");
AddSearchEvent(itemTitle: "Breaking Bad");
var result = await _controller.GetEvents(search: "matrix");
var body = GetResponseBody(result);
@@ -179,41 +143,151 @@ public class SearchStatsControllerTests : IDisposable
{
for (int i = 0; i < 5; i++)
{
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" }));
AddSearchEvent(itemTitle: $"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("TotalPages").GetInt32().ShouldBe(3);
body.GetProperty("Page").GetInt32().ShouldBe(2);
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
}
[Fact]
public async Task GetEvents_WithSortByTitleAscending_OrdersAlphabetically()
{
AddSearchEvent(itemTitle: "Charlie");
AddSearchEvent(itemTitle: "Alpha");
AddSearchEvent(itemTitle: "Bravo");
var result = await _controller.GetEvents(
sortBy: SearchEventsSortBy.Title,
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Alpha");
items[1].GetProperty("ItemTitle").GetString().ShouldBe("Bravo");
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Charlie");
}
[Fact]
public async Task GetEvents_WithSortByTimestampAscending_OldestFirst()
{
AddSearchEvent(itemTitle: "Newest", timestamp: DateTime.UtcNow);
AddSearchEvent(itemTitle: "Oldest", timestamp: DateTime.UtcNow.AddHours(-2));
AddSearchEvent(itemTitle: "Middle", timestamp: DateTime.UtcNow.AddHours(-1));
var result = await _controller.GetEvents(sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Oldest");
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Newest");
}
[Fact]
public async Task GetEvents_WithSearchStatusFilter_ReturnsOnlyMatchingStatuses()
{
AddSearchEvent(itemTitle: "A", searchStatus: SearchCommandStatus.Completed);
AddSearchEvent(itemTitle: "B", searchStatus: SearchCommandStatus.Failed);
AddSearchEvent(itemTitle: "C", searchStatus: SearchCommandStatus.TimedOut);
var result = await _controller.GetEvents(
searchStatus: [SearchCommandStatus.Completed, SearchCommandStatus.Failed]);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetEvents_WithSearchTypeFilter_ReturnsOnlyMatchingType()
{
AddSearchEvent(itemTitle: "Proactive Movie", searchType: SeekerSearchType.Proactive);
AddSearchEvent(itemTitle: "Replacement Movie", searchType: SeekerSearchType.Replacement);
var result = await _controller.GetEvents(searchType: SeekerSearchType.Replacement);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Replacement Movie");
}
[Fact]
public async Task GetEvents_WithSearchReasonFilter_ReturnsOnlyMatchingReason()
{
AddSearchEvent(itemTitle: "Missing", searchReason: SeekerSearchReason.Missing);
AddSearchEvent(itemTitle: "Cutoff", searchReason: SeekerSearchReason.QualityCutoffNotMet);
var result = await _controller.GetEvents(searchReason: SeekerSearchReason.QualityCutoffNotMet);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cutoff");
}
[Fact]
public async Task GetEvents_WithGrabbedTrue_KeepsOnlyEventsWithGrabbedItems()
{
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
var result = await _controller.GetEvents(grabbed: true);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("With Grabs");
}
[Fact]
public async Task GetEvents_WithGrabbedFalse_KeepsOnlyEventsWithoutGrabbedItems()
{
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
var result = await _controller.GetEvents(grabbed: false);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("No Grabs");
}
#endregion
#region Helpers
private void AddSearchEvent(
string? data = null,
string? instanceUrl = null,
InstanceType? instanceType = null,
string? itemTitle = null,
SeekerSearchType searchType = SeekerSearchType.Proactive,
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
List<string>? grabbedItems = null,
Guid? arrInstanceId = null,
Guid? cycleId = null,
SearchCommandStatus? searchStatus = null)
SearchCommandStatus? searchStatus = null,
DateTime? timestamp = null)
{
_eventsContext.Events.Add(new AppEvent
var appEvent = new AppEvent
{
EventType = EventType.SearchTriggered,
Message = "Search triggered",
Severity = EventSeverity.Information,
Data = data,
InstanceUrl = instanceUrl,
InstanceType = instanceType,
ArrInstanceId = arrInstanceId,
CycleId = cycleId,
SearchStatus = searchStatus,
Timestamp = DateTime.UtcNow
});
Timestamp = timestamp ?? DateTime.UtcNow
};
if (itemTitle is not null)
{
appEvent.ItemTitle = itemTitle;
appEvent.SearchType = searchType;
appEvent.SearchReason = searchReason;
appEvent.GrabbedItems = grabbedItems ?? [];
}
_eventsContext.Events.Add(appEvent);
_eventsContext.SaveChanges();
}
@@ -138,11 +138,11 @@ public class SeekerConfigControllerTests : IDisposable
});
await _dataContext.SaveChangesAsync();
// Keep interval at default (3)
// Keep interval at default (10)
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
SearchInterval = 10,
ProactiveSearchEnabled = true,
Instances =
[
@@ -167,16 +167,15 @@ public class SeekerConfigControllerTests : IDisposable
});
await _dataContext.SaveChangesAsync();
// UseCustomFormatScore was false (default), now enable it
// UseCustomFormatScore was false (default), now enable it on the instance
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
UseCustomFormatScore = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
]
};
@@ -195,25 +194,24 @@ public class SeekerConfigControllerTests : IDisposable
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
Enabled = true,
UseCustomFormatScore = true
});
await _dataContext.SaveChangesAsync();
// First enable CF score
// Syncer was running: both proactive and CF score were enabled
var config = await _dataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
config.ProactiveSearchEnabled = true;
await _dataContext.SaveChangesAsync();
// Now disable it
// Disable CF score — syncer conditions no longer met
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
UseCustomFormatScore = false,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = false }
]
};
@@ -224,38 +222,104 @@ public class SeekerConfigControllerTests : IDisposable
}
[Fact]
public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
public async Task UpdateSeekerConfig_WhenProactiveSearchDisabled_StopsSyncerJob()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true
Enabled = true,
UseCustomFormatScore = true
});
await _dataContext.SaveChangesAsync();
// Set up state: CF score already enabled, search currently disabled
// Syncer was running: both proactive and CF score were enabled
var config = await _dataContext.SeekerConfigs.FirstAsync();
config.UseCustomFormatScore = true;
config.SearchEnabled = false;
config.ProactiveSearchEnabled = true;
await _dataContext.SaveChangesAsync();
// Re-enable search
// Disable proactive search — syncer should stop even though CF score is still enabled
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = false,
UseCustomFormatScore = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.Received(1)
.StopJob(JobType.CustomFormatScoreSyncer);
}
[Fact]
public async Task UpdateSeekerConfig_WhenProactiveSearchEnabled_WithCfScoreActive_StartsAndTriggersSyncer()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true,
UseCustomFormatScore = true
});
// Syncer was NOT running: CF score enabled but proactive was off (default)
await _dataContext.SaveChangesAsync();
// Enable proactive search — syncer should start
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = 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_WhenCustomFormatScoreEnabledButProactiveDisabled_DoesNotStartSyncer()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true,
UseCustomFormatScore = false
});
// ProactiveSearchEnabled stays false (default)
await _dataContext.SaveChangesAsync();
// Enable CF score but keep proactive disabled — syncer should NOT start
var request = new UpdateSeekerConfigRequest
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = false,
Instances =
[
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
]
};
await _controller.UpdateSeekerConfig(request);
await _jobManagementService.DidNotReceive()
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
await _jobManagementService.DidNotReceive()
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
@@ -24,6 +24,8 @@ public static class SeekerTestDataFactory
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options;
var context = new DataContext(options);
@@ -40,6 +42,8 @@ public static class SeekerTestDataFactory
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options;
var context = new EventsContext(options);
@@ -0,0 +1,63 @@
using System.Text.Json;
using Cleanuparr.Api.Json;
using Shouldly;
using Xunit;
namespace Cleanuparr.Api.Tests.Json;
public class InboundNullHandlingTests
{
private static readonly JsonSerializerOptions Options = CreateOptions();
private static JsonSerializerOptions CreateOptions()
{
JsonSerializerOptions options = new();
CleanuparrJsonConfiguration.ConfigureApiInbound(options);
return options;
}
private sealed class Model
{
public List<string> Items { get; set; } = ["default"];
public string Name { get; set; } = "default-name";
public string? Optional { get; set; } = "opt";
}
[Fact]
public void ExplicitNull_OnNonNullableProperty_KeepsDefault()
{
Model result = JsonSerializer.Deserialize<Model>("""{"items": null, "name": null}""", Options)!;
result.Items.ShouldBe(["default"]);
result.Name.ShouldBe("default-name");
}
[Fact]
public void ExplicitNull_MatchesAbsent()
{
Model withNull = JsonSerializer.Deserialize<Model>("""{"items": null, "name": null}""", Options)!;
Model absent = JsonSerializer.Deserialize<Model>("{}", Options)!;
withNull.Items.ShouldBe(absent.Items);
withNull.Name.ShouldBe(absent.Name);
}
[Fact]
public void ExplicitNull_OnNullableProperty_SetsNull()
{
Model result = JsonSerializer.Deserialize<Model>("""{"optional": null}""", Options)!;
result.Optional.ShouldBeNull();
}
[Fact]
public void NonNullValue_StillDeserializes()
{
Model result = JsonSerializer.Deserialize<Model>("""{"items": ["a","b"], "name": "x"}""", Options)!;
result.Items.ShouldBe(["a", "b"]);
result.Name.ShouldBe("x");
}
}
@@ -0,0 +1,108 @@
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Api.Middleware;
using Cleanuparr.Domain.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Middleware;
public class GlobalExceptionHandlerTests
{
private static readonly ProblemDetailsFactory ProblemDetailsFactory = BuildProblemDetailsFactory();
private static ProblemDetailsFactory BuildProblemDetailsFactory()
{
ServiceCollection services = new();
services.AddLogging();
services.AddControllers();
services.AddCleanuparrProblemDetails();
return services.BuildServiceProvider().GetRequiredService<ProblemDetailsFactory>();
}
private static async Task<(bool handled, HttpContext context, ProblemDetails problemDetails)> Handle(Exception exception)
{
IProblemDetailsService problemDetailsService = Substitute.For<IProblemDetailsService>();
problemDetailsService
.TryWriteAsync(Arg.Any<ProblemDetailsContext>())
.Returns(callInfo => ValueTask.FromResult(true));
DefaultHttpContext context = new();
GlobalExceptionHandler handler = new(problemDetailsService, ProblemDetailsFactory, NullLogger<GlobalExceptionHandler>.Instance);
bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None);
ProblemDetailsContext captured = (ProblemDetailsContext)problemDetailsService
.ReceivedCalls()
.Single()
.GetArguments()[0]!;
return (handled, context, captured.ProblemDetails);
}
[Fact]
public async Task ValidationException_MapsTo400_WithMessageAsDetail()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new ValidationException("Name is required"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Title.ShouldBe("Validation failed");
problemDetails.Detail.ShouldBe("Name is required");
problemDetails.Type.ShouldNotBeNullOrEmpty();
problemDetails.Extensions.ShouldContainKey("traceId");
}
[Fact]
public async Task NotificationTestException_MapsTo400()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new NotificationTestException("Test failed: connection refused"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
problemDetails.Title.ShouldBe("Notification test failed");
problemDetails.Detail.ShouldBe("Test failed: connection refused");
}
[Fact]
public async Task RateLimitException_MapsTo429_WithRetryAfterExtensionAndHeader()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Account is locked", 30));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Status.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Title.ShouldBe("Too many requests");
problemDetails.Extensions["retryAfterSeconds"].ShouldBe(30);
context.Response.Headers.RetryAfter.ToString().ShouldBe("30");
}
[Fact]
public async Task RateLimitException_WithZeroRetry_MapsTo429_WithoutRetryAfter()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Too many pending OIDC flows", 0));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
problemDetails.Extensions.ShouldNotContainKey("retryAfterSeconds");
context.Response.Headers.RetryAfter.ToString().ShouldBeEmpty();
}
[Fact]
public async Task UnknownException_MapsTo500_WithGenericDetail_AndDoesNotLeakMessage()
{
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new InvalidOperationException("internal connection string leaked"));
handled.ShouldBeTrue();
context.Response.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError);
problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError);
problemDetails.Detail.ShouldBe("An unexpected error occurred");
problemDetails.Detail.ShouldNotContain("connection string");
}
}
@@ -0,0 +1,226 @@
using System.Net;
using Cleanuparr.Api.Middleware;
using Microsoft.AspNetCore.Http;
using Shouldly;
namespace Cleanuparr.Api.Tests.Middleware;
public class TrustedForwardedHeadersMiddlewareTests
{
private static HttpContext NewContext(IPAddress peer, Action<HttpContext>? configure = null)
{
var ctx = new DefaultHttpContext();
ctx.Connection.RemoteIpAddress = peer;
ctx.Request.Scheme = "http";
ctx.Request.Host = new HostString("backend.local");
configure?.Invoke(ctx);
return ctx;
}
[Fact]
public void Untrusted_direct_peer_leaves_everything_alone()
{
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c =>
{
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5";
c.Request.Headers["X-Forwarded-Proto"] = "https";
c.Request.Headers["X-Forwarded-Host"] = "spoofed.example.com";
});
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.1"));
ctx.Request.Scheme.ShouldBe("http");
ctx.Request.Host.Value.ShouldBe("backend.local");
}
[Fact]
public void Local_peer_no_xff_is_a_no_op()
{
var ctx = NewContext(IPAddress.Loopback);
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
}
[Fact]
public void Spoofed_local_xff_with_appended_attacker_promotes_attacker_ip_only()
{
var ctx = NewContext(IPAddress.Loopback, c =>
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, 99.99.99.99");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
}
[Fact]
public void Single_xff_entry_from_overwrite_mode_proxy_becomes_client_ip()
{
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
}
[Fact]
public void Legitimate_lan_client_through_local_proxy_resolves_to_lan_ip()
{
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "192.168.1.50");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("192.168.1.50"));
}
[Fact]
public void Custom_trusted_network_pops_through_to_real_client()
{
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "100.64.1.5, 100.64.0.7");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string> { "100.64.0.0/10" });
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("100.64.1.5"));
}
[Fact]
public void Forwarded_proto_and_host_applied_when_chain_consumed()
{
var ctx = NewContext(IPAddress.Loopback, c =>
{
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
c.Request.Headers["X-Forwarded-Proto"] = "https";
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com";
});
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Request.Scheme.ShouldBe("https");
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
}
[Fact]
public void Forwarded_proto_not_applied_when_peer_untrusted()
{
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c => c.Request.Headers["X-Forwarded-Proto"] = "https");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Request.Scheme.ShouldBe("http");
}
[Fact]
public void X_real_ip_is_ignored()
{
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Real-IP"] = "10.0.0.5");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
}
[Fact]
public void Malformed_xff_entry_fails_closed()
{
var ctx = NewContext(IPAddress.Loopback, c =>
{
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, not-an-ip";
c.Request.Headers["X-Forwarded-Proto"] = "https";
});
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
ctx.Request.Scheme.ShouldBe("http");
}
[Fact]
public void Empty_entries_in_xff_are_skipped()
{
// nginx with `proxy_set_header X-Forwarded-For "$http_x_forwarded_for, 1.2.3.4"`
// produces a leading empty entry when the input header was absent.
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = ", 99.99.99.99");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
}
[Fact]
public void Ipv4_mapped_ipv6_loopback_is_treated_as_trusted()
{
// Kestrel may surface "::ffff:127.0.0.1" as the peer.
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
var ctx = NewContext(mapped, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
}
[Fact]
public void Forwarded_proto_with_multiple_values_uses_only_first_token()
{
// Chained proxies that append (rather than overwrite) X-Forwarded-Proto
// produce comma-separated values like "https, http". Only the leftmost
// hop's value should be applied — matching how XFF is handled.
var ctx = NewContext(IPAddress.Loopback, c =>
{
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
c.Request.Headers["X-Forwarded-Proto"] = "https, http";
});
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Request.Scheme.ShouldBe("https");
}
[Fact]
public void Forwarded_proto_with_unknown_scheme_is_ignored()
{
// Anything outside the http/https allowlist is dropped to keep
// arbitrary values (e.g. "javascript:") from flowing into URLs.
var ctx = NewContext(IPAddress.Loopback, c =>
{
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
c.Request.Headers["X-Forwarded-Proto"] = "javascript:";
});
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Request.Scheme.ShouldBe("http");
}
[Fact]
public void Forwarded_host_with_multiple_values_uses_only_first_token()
{
// Same multi-hop concern as X-Forwarded-Proto — the host string must
// not end up as "a.example, b.example".
var ctx = NewContext(IPAddress.Loopback, c =>
{
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com, attacker.example.com";
});
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
}
[Fact]
public void Malformed_entry_mid_chain_does_not_partially_mutate_remote_ip()
{
// Walk right-to-left: 10.0.0.5 (trusted) is popped first, then
// "not-an-ip" fails. The pre-fix middleware committed mutation eagerly,
// leaving RemoteIpAddress = 10.0.0.5. Fix: validate-then-commit, so the
// original peer is preserved when any chain entry is malformed.
var ctx = NewContext(IPAddress.Loopback, c =>
c.Request.Headers["X-Forwarded-For"] = "not-an-ip, 10.0.0.5");
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
}
}
@@ -0,0 +1,136 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Cleanuparr.Api.Tests.TestHelpers;
/// <summary>
/// Shared SQLite in-memory factory for controller tests that need a populated DataContext.
/// Seeds one row per config table so first-or-default reads succeed.
/// </summary>
public static class ConfigControllerTestDataFactory
{
public static DataContext CreateDataContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
SeedDefaults(context);
return context;
}
public static EventsContext CreateEventsContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options;
var context = new EventsContext(options);
context.Database.EnsureCreated();
return context;
}
public static void ConfigureProblemDetails(ControllerBase controller)
{
ProblemDetailsFactory factory = Substitute.For<ProblemDetailsFactory>();
factory
.CreateProblemDetails(
Arg.Any<HttpContext>(),
Arg.Any<int?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>())
.Returns(call => new ProblemDetails
{
Status = call.ArgAt<int?>(1),
Title = call.ArgAt<string?>(2),
Detail = call.ArgAt<string?>(4),
});
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext(),
};
controller.ProblemDetailsFactory = factory;
}
private static void SeedDefaults(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 = [],
});
context.BlacklistSyncConfigs.Add(new BlacklistSyncConfig
{
Id = Guid.NewGuid(),
Enabled = false,
CronExpression = "0 0 * * * ?",
});
context.SaveChanges();
}
}
@@ -0,0 +1,35 @@
using Cleanuparr.Api.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Api.Tests.TestHelpers;
/// <summary>
/// Attaches a minimal MVC <see cref="ControllerContext"/> (with a real <see cref="ProblemDetailsFactory"/>
/// and <see cref="HttpContext"/>) to a directly-instantiated controller so that
/// <c>this.ProblemResult(...)</c> can build problem-details responses in unit tests.
/// </summary>
public static class ControllerTestContext
{
private static readonly IServiceProvider Services = BuildServices();
private static IServiceProvider BuildServices()
{
ServiceCollection services = new();
services.AddLogging();
services.AddControllers();
services.AddCleanuparrProblemDetails();
return services.BuildServiceProvider();
}
public static void Attach(ControllerBase controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { RequestServices = Services },
};
controller.ProblemDetailsFactory = Services.GetRequiredService<ProblemDetailsFactory>();
}
}
@@ -16,19 +16,22 @@ public static class TrustedNetworkAuthenticationDefaults
public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly DataContext _dataContext;
public TrustedNetworkAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
UrlEncoder encoder,
DataContext dataContext)
: base(options, logger, encoder)
{
_dataContext = dataContext;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Load auth config from database
await using var dataContext = DataContext.CreateStaticInstance();
var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
var config = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
if (config is null || !config.Auth.DisableAuthForLocalAddresses)
{
@@ -36,7 +39,7 @@ public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<Authent
}
// Determine client IP
var clientIp = GetClientIp(config.Auth.TrustForwardedHeaders);
var clientIp = ResolveClientIp(Context);
if (clientIp is null)
{
return AuthenticateResult.NoResult();
@@ -73,42 +76,13 @@ public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<Authent
return AuthenticateResult.Success(ticket);
}
private IPAddress? GetClientIp(bool trustForwardedHeaders) =>
ResolveClientIp(Context, trustForwardedHeaders);
public static IPAddress? ResolveClientIp(HttpContext httpContext, bool trustForwardedHeaders)
{
var remoteIp = httpContext.Connection.RemoteIpAddress;
if (remoteIp is null)
{
return null;
}
// Only trust forwarded headers if the direct connection is from a local address
if (trustForwardedHeaders && remoteIp.IsLocalAddress())
{
// Check X-Forwarded-For first, then X-Real-IP
var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor))
{
// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
// The first one is the original client
var firstIp = forwardedFor.Split(',')[0].Trim();
if (IPAddress.TryParse(firstIp, out var parsedIp))
{
return parsedIp;
}
}
var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault();
if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out var realParsedIp))
{
return realParsedIp;
}
}
return remoteIp;
}
/// <summary>
/// Returns the connection's remote IP address. Callers must run <see cref="Cleanuparr.Api.Middleware.TrustedForwardedHeadersMiddleware"/>
/// earlier in the pipeline so that <c>X-Forwarded-*</c> headers from trusted proxy chains have already been resolved into <c>Connection.RemoteIpAddress</c>.
/// </summary>
/// <param name="httpContext">The current HTTP context.</param>
/// <returns>The resolved client IP, or <c>null</c> when unavailable.</returns>
public static IPAddress? ResolveClientIp(HttpContext httpContext) => httpContext.Connection.RemoteIpAddress;
public static bool IsTrustedAddress(IPAddress clientIp, List<string> trustedNetworks)
{
@@ -7,6 +7,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishReadyToRun>true</PublishReadyToRun>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<EnableMacOSCodeSign>false</EnableMacOSCodeSign>
<CodeSignOnCopy>false</CodeSignOnCopy>
<_CodeSignDuringBuild>false</_CodeSignDuringBuild>
@@ -20,6 +22,8 @@
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
<ProjectReference Include="..\Cleanuparr.Persistence.Sqlite\Cleanuparr.Persistence.Sqlite.csproj" />
<ProjectReference Include="..\Cleanuparr.Persistence.Postgres\Cleanuparr.Persistence.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,47 @@
using Cleanuparr.Infrastructure.Features.DatabaseMigration;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Configuration;
namespace Cleanuparr.Api.Commands;
public static class MigrateToPostgresCommand
{
public const string Name = "migrate-to-postgres";
public const string ForceFlag = "--force";
public static bool Matches(string[] args) => args.Length > 0 && args[0] == Name;
public static async Task<int> RunAsync(string[] args)
{
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile(ConfigurationPathProvider.GetConfigFilePath(), optional: true)
.AddEnvironmentVariables()
.Build();
DatabaseConfigProvider.Initialize(configuration);
bool force = args.Contains(ForceFlag);
SqliteToPostgresMigrator migrator = new();
MigrationResult result = await migrator.RunAsync(force, new ConsoleProgress(), CancellationToken.None);
if (!result.Success)
{
Console.Error.WriteLine($"Migration failed: {result.Error}");
return 1;
}
Console.WriteLine("Migration complete. Row counts:");
foreach (KeyValuePair<string, int> entry in result.TableCounts.OrderBy(pair => pair.Key))
{
Console.WriteLine($" {entry.Key}: {entry.Value}");
}
Console.WriteLine("Set DATABASE_PROVIDER=postgres and restart Cleanuparr to run on PostgreSQL.");
Console.WriteLine("(Your SQLite databases were upgraded to the current schema in place; their data was preserved.)");
return 0;
}
private sealed class ConsoleProgress : IProgress<string>
{
public void Report(string value) => Console.WriteLine($" {value}");
}
}
@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Common;
public static class TimelineWindow
{
public const int MinHours = 1;
public const int MaxHours = 8760;
public static int ClampHours(int hours) => Math.Clamp(hours, MinHours, MaxHours);
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Api.Contracts.Responses;
public class PaginatedResult<T>
{
public List<T> Items { get; set; } = new();
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
[JsonIgnore]
public bool HasPrevious => Page > 1;
[JsonIgnore]
public bool HasNext => Page < TotalPages;
}
@@ -1,7 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Api.Common;
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Stats;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Providers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -14,23 +18,25 @@ namespace Cleanuparr.Api.Controllers;
public class EventsController : ControllerBase
{
private readonly EventsContext _context;
private readonly IDatabaseProvider _databaseProvider;
public EventsController(EventsContext context)
public EventsController(EventsContext context, IDatabaseProvider databaseProvider)
{
_context = context;
_databaseProvider = databaseProvider;
}
/// <summary>
/// Gets events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<AppEvent>>> GetEvents(
public async Task<ActionResult<PaginatedResult<EventListItem>>> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 100,
[FromQuery] int pageSize = 50,
[FromQuery] string? severity = null,
[FromQuery] string? eventType = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] DateTimeOffset? fromDate = null,
[FromQuery] DateTimeOffset? toDate = null,
[FromQuery] string? search = null,
[FromQuery] string? jobRunId = null)
{
@@ -42,42 +48,47 @@ public class EventsController : ControllerBase
if (pageSize < 1)
{
pageSize = 100;
pageSize = 50;
}
if (pageSize > 1000)
if (pageSize > 500)
{
pageSize = 1000; // Cap at 1000 for performance
pageSize = 500;
}
var query = _context.Events.AsQueryable();
IQueryable<EventListItem> query = _context.Events
.Select(EventListItem.FromEvent);
// Apply filters
if (!string.IsNullOrWhiteSpace(severity))
{
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
if (Enum.TryParse<EventSeverity>(severity, true, out EventSeverity severityEnum))
{
query = query.Where(e => e.Severity == severityEnum);
}
}
if (!string.IsNullOrWhiteSpace(eventType))
{
if (Enum.TryParse<EventType>(eventType, true, out var eventTypeEnum))
if (Enum.TryParse<EventType>(eventType, true, out EventType eventTypeEnum))
{
query = query.Where(e => e.EventType == eventTypeEnum);
}
}
// Apply date range filters
if (fromDate.HasValue)
{
query = query.Where(e => e.Timestamp >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply job run ID exact-match filter
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out Guid jobRunGuid))
{
query = query.Where(e => e.JobRunId == jobRunGuid);
}
@@ -87,31 +98,29 @@ public class EventsController : ControllerBase
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
EF.Functions.Like(e.Message.ToLower(), pattern, "\\") ||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\")) ||
EF.Functions.Like(e.TrackingId.ToString().ToLower(), pattern, "\\") ||
EF.Functions.Like(e.JobRunId.ToString().ToLower(), pattern, "\\")
);
}
// Count total matching records for pagination
var totalCount = await query.CountAsync();
int totalCount = await query.CountAsync();
// Calculate pagination
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
// Get paginated data
var events = await query
int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
int skip = (page - 1) * pageSize;
List<EventListItem> events = await query
.OrderByDescending(e => e.Timestamp)
.ThenByDescending(e => e.Id)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Return paginated result
var result = new PaginatedResult<AppEvent>
PaginatedResult<EventListItem> result = new()
{
Items = events,
Page = page,
@@ -119,7 +128,7 @@ public class EventsController : ControllerBase
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
@@ -130,7 +139,7 @@ public class EventsController : ControllerBase
public async Task<ActionResult<AppEvent>> GetEvent(Guid id)
{
var eventEntity = await _context.Events.FindAsync(id);
if (eventEntity == null)
return NotFound();
@@ -151,21 +160,6 @@ public class EventsController : ControllerBase
return Ok(events);
}
/// <summary>
/// Manually triggers cleanup of old events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
await _context.Events
.Where(e => e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok();
}
/// <summary>
/// Gets unique event types
/// </summary>
@@ -185,48 +179,69 @@ public class EventsController : ControllerBase
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
}
/// <summary>
/// Represents a paginated result set
/// </summary>
/// <typeparam name="T">Type of items in the result</typeparam>
public class PaginatedResult<T>
{
/// <summary>
/// The items in the current page
/// </summary>
public List<T> Items { get; set; } = new();
/// <summary>
/// Current page number (1-based)
/// </summary>
public int Page { get; set; }
/// <summary>
/// Number of items per page
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// Total number of items across all pages
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Total number of pages
/// </summary>
public int TotalPages { get; set; }
/// <summary>
/// Whether there is a previous page
/// </summary>
[JsonIgnore]
public bool HasPrevious => Page > 1;
/// <summary>
/// Whether there is a next page
/// </summary>
[JsonIgnore]
public bool HasNext => Page < TotalPages;
}
[HttpGet("timeline")]
public async Task<ActionResult<EventTypeTimelineResponse>> GetTimeline([FromQuery] int hours = 720)
{
hours = TimelineWindow.ClampHours(hours);
DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset cutoff = now.AddHours(-hours);
TimelineBucketSize size = TimelineBucketing.DefaultFor(hours);
string bucketExpr = _databaseProvider.GetTimelineBucketExpr(size);
string? schema = _databaseProvider.GetSchema(DbContextKind.Events);
string eventsTable = schema is null ? "events" : $"{schema}.events";
List<BucketTypeCount> rows = await _context.Database
.SqlQueryRaw<BucketTypeCount>(
$$"""
SELECT {{bucketExpr}} AS "bucket", event_type AS "event_type", COUNT(*) AS "count"
FROM {{eventsTable}}
WHERE "timestamp" >= {0}
GROUP BY {{bucketExpr}}, event_type
""",
cutoff)
.ToListAsync();
Dictionary<(DateTimeOffset Bucket, EventType Type), int> byBucketType = new();
HashSet<EventType> presentSet = [];
foreach (BucketTypeCount row in rows)
{
DateTimeOffset bucket = TimelineBucketing.ParseKey(row.Bucket, size);
EventType type = Enum.Parse<EventType>(row.EventType, ignoreCase: true);
byBucketType[(bucket, type)] = row.Count;
presentSet.Add(type);
}
List<EventType> presentTypes = presentSet
.OrderBy(t => (int)t)
.ToList();
List<EventTypeTimelineBucket> buckets = [];
foreach (DateTimeOffset bucket in TimelineBucketing.Buckets(cutoff, now, size))
{
Dictionary<string, int> counts = new();
foreach (EventType type in presentTypes)
{
if (byBucketType.TryGetValue((bucket, type), out int count) && count > 0)
{
counts[type.ToString()] = count;
}
}
buckets.Add(new EventTypeTimelineBucket { Date = bucket, Counts = counts });
}
return Ok(new EventTypeTimelineResponse
{
Types = presentTypes.Select(t => t.ToString()).ToList(),
Buckets = buckets,
});
}
private sealed class BucketTypeCount
{
public string Bucket { get; set; } = string.Empty;
public string EventType { get; set; } = string.Empty;
public int Count { get; set; }
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Infrastructure.Health;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,17 +13,13 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class HealthCheckController : ControllerBase
{
private readonly ILogger<HealthCheckController> _logger;
private readonly IHealthCheckService _healthCheckService;
/// <summary>
/// Initializes a new instance of the <see cref="HealthCheckController"/> class
/// </summary>
public HealthCheckController(
ILogger<HealthCheckController> logger,
IHealthCheckService healthCheckService)
public HealthCheckController(IHealthCheckService healthCheckService)
{
_logger = logger;
_healthCheckService = healthCheckService;
}
@@ -32,16 +29,8 @@ public class HealthCheckController : ControllerBase
[HttpGet]
public IActionResult GetAllHealth()
{
try
{
var healthStatuses = _healthCheckService.GetAllClientHealth();
return Ok(healthStatuses);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving client health statuses");
return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
}
var healthStatuses = _healthCheckService.GetAllClientHealth();
return Ok(healthStatuses);
}
/// <summary>
@@ -50,21 +39,13 @@ public class HealthCheckController : ControllerBase
[HttpGet("{id:guid}")]
public IActionResult GetClientHealth(Guid id)
{
try
var healthStatus = _healthCheckService.GetClientHealth(id);
if (healthStatus == null)
{
var healthStatus = _healthCheckService.GetClientHealth(id);
if (healthStatus == null)
{
return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
}
return this.ProblemResult(StatusCodes.Status404NotFound, $"Health status for client with ID '{id}' not found");
}
return Ok(healthStatus);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving health status for client {id}", id);
return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
}
return Ok(healthStatus);
}
/// <summary>
@@ -73,16 +54,8 @@ public class HealthCheckController : ControllerBase
[HttpPost("check")]
public async Task<IActionResult> CheckAllHealth()
{
try
{
var results = await _healthCheckService.CheckAllClientsHealthAsync();
return Ok(results);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for all clients");
return StatusCode(500, new { Error = "An error occurred while checking client health" });
}
var results = await _healthCheckService.CheckAllClientsHealthAsync();
return Ok(results);
}
/// <summary>
@@ -91,15 +64,7 @@ public class HealthCheckController : ControllerBase
[HttpPost("check/{id:guid}")]
public async Task<IActionResult> CheckClientHealth(Guid id)
{
try
{
var result = await _healthCheckService.CheckClientHealthAsync(id);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking health for client {id}", id);
return StatusCode(500, new { Error = "An error occurred while checking client health" });
}
var result = await _healthCheckService.CheckClientHealthAsync(id);
return Ok(result);
}
}
@@ -36,13 +36,13 @@ public class HealthController : ControllerBase
registration => registration.Tags.Contains("liveness"));
return result.Status == HealthStatus.Healthy
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
? Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTimeOffset.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTimeOffset.UtcNow });
}
}
@@ -62,13 +62,13 @@ public class HealthController : ControllerBase
if (result.Status == HealthStatus.Healthy)
{
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
return Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow });
}
// For readiness, we consider degraded as not ready
return StatusCode(503, new {
status = "not_ready",
timestamp = DateTime.UtcNow,
timestamp = DateTimeOffset.UtcNow,
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
.ToDictionary(e => e.Key, e => new {
status = e.Value.Status.ToString().ToLowerInvariant(),
@@ -79,7 +79,7 @@ public class HealthController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Readiness check failed");
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTimeOffset.UtcNow });
}
}
@@ -97,7 +97,7 @@ public class HealthController : ControllerBase
var response = new
{
status = result.Status.ToString().ToLowerInvariant(),
timestamp = DateTime.UtcNow,
timestamp = DateTimeOffset.UtcNow,
totalDuration = result.TotalDuration.TotalMilliseconds,
entries = result.Entries.ToDictionary(
e => e.Key,
@@ -122,7 +122,7 @@ public class HealthController : ControllerBase
return StatusCode(503, new {
status = "unhealthy",
error = "Detailed health check failed",
timestamp = DateTime.UtcNow
timestamp = DateTimeOffset.UtcNow
});
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Models;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
@@ -13,47 +14,29 @@ namespace Cleanuparr.Api.Controllers;
public class JobsController : ControllerBase
{
private readonly IJobManagementService _jobManagementService;
private readonly ILogger<JobsController> _logger;
public JobsController(IJobManagementService jobManagementService, ILogger<JobsController> logger)
public JobsController(IJobManagementService jobManagementService)
{
_jobManagementService = jobManagementService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAllJobs()
{
try
{
var result = await _jobManagementService.GetAllJobs();
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all jobs");
return StatusCode(500, "An error occurred while retrieving jobs");
}
var result = await _jobManagementService.GetAllJobs();
return Ok(result);
}
[HttpGet("{jobType}")]
public async Task<IActionResult> GetJob(JobType jobType)
{
try
var jobInfo = await _jobManagementService.GetJob(jobType);
if (jobInfo.Status == "Not Found")
{
var jobInfo = await _jobManagementService.GetJob(jobType);
if (jobInfo.Status == "Not Found")
{
return NotFound($"Job '{jobType}' not found");
}
return Ok(jobInfo);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting job {jobType}", jobType);
return StatusCode(500, $"An error occurred while retrieving job '{jobType}'");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Job '{jobType}' not found");
}
return Ok(jobInfo);
}
[HttpPost("{jobType}/start")]
@@ -61,27 +44,19 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually controlled");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
}
try
// Get the schedule from the request body if provided
JobSchedule jobSchedule = scheduleRequest.Schedule;
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
if (!result)
{
// Get the schedule from the request body if provided
JobSchedule jobSchedule = scheduleRequest.Schedule;
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
if (!result)
{
return BadRequest($"Failed to start job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' started successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting job {jobType}", jobType);
return StatusCode(500, $"An error occurred while starting job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to start job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' started successfully" });
}
[HttpPost("{jobType}/trigger")]
@@ -89,24 +64,16 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job cannot be manually triggered");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually triggered");
}
try
var result = await _jobManagementService.TriggerJobOnce(jobType);
if (!result)
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
if (!result)
{
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to trigger job '{jobType}' - job may not exist or be configured");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
}
[HttpPut("{jobType}/schedule")]
@@ -114,28 +81,20 @@ public class JobsController : ControllerBase
{
if (jobType == JobType.Seeker)
{
return BadRequest("The Seeker job schedule cannot be manually modified");
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job schedule cannot be manually modified");
}
if (scheduleRequest?.Schedule == null)
{
return BadRequest("Schedule is required");
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
}
try
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
if (!result)
{
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
if (!result)
{
return BadRequest($"Failed to update schedule for job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job {jobType} schedule", jobType);
return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'");
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to update schedule for job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
@@ -25,17 +26,28 @@ public class ManualEventsController : ControllerBase
[HttpGet]
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 100,
[FromQuery] int pageSize = 50,
[FromQuery] bool? isResolved = null,
[FromQuery] string? severity = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] DateTimeOffset? fromDate = null,
[FromQuery] DateTimeOffset? toDate = null,
[FromQuery] string? search = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
if (page < 1)
{
page = 1;
}
if (pageSize < 1)
{
pageSize = 50;
}
if (pageSize > 500)
{
pageSize = 500;
}
var query = _context.ManualEvents.AsQueryable();
@@ -67,10 +79,8 @@ public class ManualEventsController : ControllerBase
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.InstanceUrl, pattern) ||
EF.Functions.Like(e.DownloadClientName, pattern)
EF.Functions.Like(e.Message.ToLower(), pattern, "\\") ||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\"))
);
}
@@ -127,11 +137,28 @@ public class ManualEventsController : ControllerBase
return NotFound();
eventEntity.IsResolved = true;
eventEntity.ResolvedAt = DateTimeOffset.UtcNow;
await _context.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Marks all unresolved manual events as resolved
/// </summary>
[HttpPost("resolve_all")]
public async Task<ActionResult<object>> ResolveAllManualEvents()
{
DateTimeOffset resolvedAt = DateTimeOffset.UtcNow;
int resolvedCount = await _context.ManualEvents
.Where(e => !e.IsResolved)
.ExecuteUpdateAsync(setter => setter
.SetProperty(e => e.IsResolved, true)
.SetProperty(e => e.ResolvedAt, resolvedAt));
return Ok(new { ResolvedCount = resolvedCount });
}
/// <summary>
/// Gets manual event statistics
/// </summary>
@@ -166,19 +193,4 @@ public class ManualEventsController : ControllerBase
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
/// <summary>
/// Manually triggers cleanup of old resolved events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var deletedCount = await _context.ManualEvents
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok(new { DeletedCount = deletedCount });
}
}
@@ -5,26 +5,26 @@ using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Aggregated statistics endpoint for dashboard integrations
/// Aggregated statistics endpoint for dashboard integrations.
/// Deprecated. Use <c>GET /api/v2/stats</c> instead.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StatsController : ControllerBase
{
private readonly ILogger<StatsController> _logger;
private static readonly DateTimeOffset SunsetDate = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
private readonly IStatsService _statsService;
public StatsController(
ILogger<StatsController> logger,
IStatsService statsService)
public StatsController(IStatsService statsService)
{
_logger = logger;
_statsService = statsService;
}
/// <summary>
/// Gets aggregated application statistics for the specified timeframe
/// Gets aggregated application statistics for the specified timeframe.
/// Deprecated. Use <c>GET /api/v2/stats</c> instead. Responses carry Deprecation/Link headers.
/// </summary>
/// <param name="hours">Timeframe in hours (default 24, range 1-720)</param>
/// <param name="includeEvents">Number of recent events to include (0 = none, max 100)</param>
@@ -35,19 +35,22 @@ public class StatsController : ControllerBase
[FromQuery] int includeEvents = 0,
[FromQuery] int includeStrikes = 0)
{
try
{
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
Response.Headers["Deprecation"] = "true";
Response.Headers["Sunset"] = SunsetDate.ToString("R");
Response.Headers["Link"] =
"</api/v2/stats>; rel=\"successor-version\", " +
"<https://cleanuparr.github.io/Cleanuparr/docs/configuration/stats>; rel=\"deprecation\"";
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
catch (Exception ex)
if (DateTimeOffset.UtcNow >= SunsetDate)
{
_logger.LogError(ex, "Error retrieving stats");
return StatusCode(500, new { Error = "An error occurred while retrieving stats" });
return NotFound();
}
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
return Ok(stats);
}
}
@@ -0,0 +1,64 @@
using Cleanuparr.Api.Common;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Stats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/v2/stats")]
[Authorize]
public class StatsV2Controller : ControllerBase
{
private readonly IStatsService _statsService;
public StatsV2Controller(IStatsService statsService)
{
_statsService = statsService;
}
/// <summary>
/// Aggregated statistics for the given timeframe. Every section except health is scoped to the timeframe and, by
/// default, excludes dry-run activity.
/// </summary>
/// <param name="hours">Timeframe in hours (default 168, range 1-8760)</param>
/// <param name="includeDryRun">Include dry-run activity in the timeframe-scoped sections (default false)</param>
[HttpGet]
public async Task<IActionResult> GetStats([FromQuery] int hours = 168, [FromQuery] bool includeDryRun = false)
{
hours = TimelineWindow.ClampHours(hours);
StatsV2Response stats = await _statsService.GetStatsV2Async(hours, includeDryRun);
return Ok(stats);
}
/// <summary>
/// Bucketed timeline for a single metric.
/// </summary>
/// <param name="metric">strikesIssued | recovered | removed | malwareBlocked | events</param>
/// <param name="hours">Timeframe in hours (default 720, range 1-8760)</param>
/// <param name="bucket">Bucket size: hour | day | week | month. When omitted, hourly for timeframes up to 24h, daily otherwise.</param>
/// <param name="includeDryRun">Include dry-run activity (default false)</param>
[HttpGet("timeline")]
public async Task<IActionResult> GetTimeline(
[FromQuery] string metric = "events",
[FromQuery] int hours = 720,
[FromQuery] string? bucket = null,
[FromQuery] bool includeDryRun = false)
{
TimelineBucketSize? size = null;
if (!string.IsNullOrWhiteSpace(bucket))
{
if (!Enum.TryParse(bucket, ignoreCase: true, out TimelineBucketSize parsed) || !Enum.IsDefined(parsed))
{
return BadRequest($"Unsupported bucket '{bucket}'. Supported values: hour, day, week, month.");
}
size = parsed;
}
hours = TimelineWindow.ClampHours(hours);
List<TimelineBucketDto> series = await _statsService.GetTimelineAsync(metric, hours, size, includeDryRun);
return Ok(series);
}
}
@@ -13,16 +13,13 @@ namespace Cleanuparr.Api.Controllers;
[Authorize]
public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
public StatusController(
ILogger<StatusController> logger,
DataContext dataContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
@@ -30,247 +27,219 @@ public class StatusController : ControllerBase
[HttpGet]
public async Task<IActionResult> GetSystemStatus()
{
try
{
var process = Process.GetCurrentProcess();
// Get configuration
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var sonarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Sonarr);
var radarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Radarr);
var lidarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
Application = new
{
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
process.StartTime,
UpTime = DateTime.Now - process.StartTime,
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
ProcessorTime = process.TotalProcessorTime
},
DownloadClient = new
{
// TODO
},
MediaManagers = new
{
Sonarr = new
{
InstanceCount = sonarrConfig.Instances.Count
},
Radarr = new
{
InstanceCount = radarrConfig.Instances.Count
},
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};
using var process = Process.GetCurrentProcess();
return Ok(status);
}
catch (Exception ex)
// Get configuration
var sonarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Sonarr);
var radarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Radarr);
var lidarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
_logger.LogError(ex, "Error retrieving system status");
return StatusCode(500, "An error occurred while retrieving system status");
}
Application = new
{
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
process.StartTime,
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
ProcessorTime = process.TotalProcessorTime
},
DownloadClient = new
{
// TODO
},
MediaManagers = new
{
Sonarr = new
{
InstanceCount = sonarrConfig.Instances.Count
},
Radarr = new
{
InstanceCount = radarrConfig.Instances.Count
},
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};
return Ok(status);
}
[HttpGet("download-client")]
public async Task<IActionResult> GetDownloadClientStatus()
{
try
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var result = new Dictionary<string, object>();
// Check for configured clients
if (downloadClients.Count > 0)
{
var downloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
var result = new Dictionary<string, object>();
// Check for configured clients
if (downloadClients.Count > 0)
var clientsStatus = new List<object>();
foreach (var client in downloadClients)
{
var clientsStatus = new List<object>();
foreach (var client in downloadClients)
clientsStatus.Add(new
{
clientsStatus.Add(new
{
client.Id,
client.Name,
Type = client.TypeName,
client.Host,
client.Enabled,
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
});
}
result["Clients"] = clientsStatus;
client.Id,
client.Name,
Type = client.TypeName,
client.Host,
client.Enabled,
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
});
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving download client status");
return StatusCode(500, "An error occurred while retrieving download client status");
result["Clients"] = clientsStatus;
}
return Ok(result);
}
[HttpGet("arrs")]
public async Task<IActionResult> GetMediaManagersStatus()
{
try
var status = new Dictionary<string, object>();
// Get configurations
var enabledSonarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Sonarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledRadarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Radarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledLidarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Lidarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
// Check Sonarr instances
var sonarrStatus = new List<object>();
foreach (var instance in enabledSonarrInstances)
{
var status = new Dictionary<string, object>();
// Get configurations
var enabledSonarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Sonarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledRadarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Radarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();
var enabledLidarrInstances = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.Where(x => x.Type == InstanceType.Lidarr)
.SelectMany(x => x.Instances)
.Where(x => x.Enabled)
.AsNoTracking()
.ToListAsync();;
// Check Sonarr instances
var sonarrStatus = new List<object>();
foreach (var instance in enabledSonarrInstances)
try
{
try
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
sonarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
status["Sonarr"] = sonarrStatus;
// Check Radarr instances
var radarrStatus = new List<object>();
foreach (var instance in enabledRadarrInstances)
catch (Exception ex)
{
try
sonarrStatus.Add(new
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
status["Radarr"] = radarrStatus;
// Check Lidarr instances
var lidarrStatus = new List<object>();
foreach (var instance in enabledLidarrInstances)
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Lidarr"] = lidarrStatus;
return Ok(status);
}
catch (Exception ex)
status["Sonarr"] = sonarrStatus;
// Check Radarr instances
var radarrStatus = new List<object>();
foreach (var instance in enabledRadarrInstances)
{
_logger.LogError(ex, "Error retrieving media managers status");
return StatusCode(500, "An error occurred while retrieving media managers status");
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
radarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Radarr"] = radarrStatus;
// Check Lidarr instances
var lidarrStatus = new List<object>();
foreach (var instance in enabledLidarrInstances)
{
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = true,
Message = "Successfully connected"
});
}
catch (Exception ex)
{
lidarrStatus.Add(new
{
instance.Name,
instance.Url,
IsConnected = false,
Message = $"Connection failed: {ex.Message}"
});
}
}
status["Lidarr"] = lidarrStatus;
return Ok(status);
}
}
@@ -1,3 +1,5 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Features.Strikes.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
@@ -29,9 +31,20 @@ public class StrikesController : ControllerBase
[FromQuery] string? search = null,
[FromQuery] string? type = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
if (page < 1)
{
page = 1;
}
if (pageSize < 1)
{
pageSize = 50;
}
if (pageSize > 500)
{
pageSize = 500;
}
var query = _context.DownloadItems
.Include(d => d.Strikes)
@@ -49,8 +62,8 @@ public class StrikesController : ControllerBase
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(d =>
EF.Functions.Like(d.Title, pattern) ||
EF.Functions.Like(d.DownloadId, pattern));
EF.Functions.Like(d.Title.ToLower(), pattern, "\\") ||
EF.Functions.Like(d.DownloadId.ToLower(), pattern, "\\"));
}
var totalCount = await query.CountAsync();
@@ -159,39 +172,3 @@ public class StrikesController : ControllerBase
return NoContent();
}
}
public class DownloadItemStrikesDto
{
public Guid DownloadItemId { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int TotalStrikes { get; set; }
public Dictionary<string, int> StrikesByType { get; set; } = new();
public DateTime LatestStrikeAt { get; set; }
public DateTime FirstStrikeAt { get; set; }
public 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; } = [];
}
public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
public bool IsDryRun { get; set; }
}
public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
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,5 +1,4 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Diagnostics;
using Cleanuparr.Api.Filters;
using Cleanuparr.Api.Json;
using Cleanuparr.Infrastructure.Health;
@@ -7,6 +6,7 @@ using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.Http.Json;
using System.Text;
using Cleanuparr.Api.Middleware;
using Cleanuparr.Shared.Configuration;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Api.DependencyInjection;
@@ -15,14 +15,7 @@ public static class ApiDI
{
public static IServiceCollection AddApiServices(this IServiceCollection services)
{
services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.SerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
options.SerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
});
services.Configure<JsonOptions>(options => CleanuparrJsonConfiguration.ConfigureApiInbound(options.SerializerOptions));
// Make JsonSerializerOptions available for injection
services.AddSingleton(sp =>
@@ -31,53 +24,64 @@ public static class ApiDI
// Add API-specific services
services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
options.JsonSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
});
.AddJsonOptions(options => CleanuparrJsonConfiguration.ConfigureApiInbound(options.JsonSerializerOptions));
services.AddEndpointsApiExplorer();
// Add SignalR for real-time updates
services
.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.PayloadSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
options.PayloadSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
});
.AddJsonProtocol(options => CleanuparrJsonConfiguration.ConfigureApi(options.PayloadSerializerOptions));
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
services.AddCleanuparrProblemDetails();
services.AddExceptionHandler<GlobalExceptionHandler>();
return services;
}
/// <summary>
/// Registers RFC 9457 problem-details responses for both the exception handler and
/// [ApiController] model-state validation, attaching a uniform Activity-tied traceId.
/// </summary>
public static IServiceCollection AddCleanuparrProblemDetails(this IServiceCollection services)
{
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions.TryAdd(
"traceId", Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
});
return services;
}
public static WebApplication ConfigureApi(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
// Map unhandled exceptions to RFC 9457 problem-details responses (GlobalExceptionHandler).
// Registered first so it also covers exceptions thrown by downstream middleware.
app.UseExceptionHandler();
// Enable compression
app.UseResponseCompression();
// Serve static files without caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
});
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
// Resolve the real client IP / scheme / host from X-Forwarded-* headers
app.UseMiddleware<TrustedForwardedHeadersMiddleware>();
// Block non-auth requests until setup is complete
app.UseMiddleware<SetupGuardMiddleware>();
app.UseCors("Any");
if (app.Environment.IsDevelopment())
{
app.UseCors("DevSpa");
}
app.UseRouting();
app.UseAuthentication();
@@ -87,7 +91,7 @@ public static class ApiDI
// Custom SPA fallback to inject base path
app.MapFallback(async context =>
{
var basePath = app.Configuration.GetValue<string>("BASE_PATH") ?? "/";
var basePath = app.Configuration.GetValue<string>(ConfigurationKeys.BasePath) ?? "/";
// Normalize the base path (remove trailing slash if not root)
if (basePath != "/" && basePath.EndsWith("/"))
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization;
using Cleanuparr.Api.Json;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
@@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -41,9 +40,7 @@ public static class MainDI
{
cfg.ConfigureJsonSerializerOptions(options =>
{
options.PropertyNameCaseInsensitive = true;
options.Converters.Add(new JsonStringEnumConverter());
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
CleanuparrJsonConfiguration.ConfigureCore(options);
return options;
});
@@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
@@ -17,6 +18,7 @@ using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Stats;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Providers;
namespace Cleanuparr.Api.DependencyInjection;
@@ -27,6 +29,7 @@ public static class ServicesDI
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<UsersContext>()
.AddSingleton<IDatabaseProvider>(_ => DatabaseProviderFactory.Current)
.AddSingleton<IJwtService, JwtService>()
.AddSingleton<IPasswordService, PasswordService>()
.AddSingleton<ITotpService, TotpService>()
@@ -47,6 +50,10 @@ public static class ServicesDI
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
.AddScoped<IDeadTorrentService, DeadTorrentService>()
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
.AddScoped<Seeker>()
.AddScoped<CustomFormatScoreSyncer>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
@@ -57,9 +64,11 @@ public static class ServicesDI
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<IConnectivityChecker, ConnectivityChecker>()
.AddScoped<FileReader>()
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IQueueRuleManager, QueueRuleManager>()
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
.AddScoped<ISeedingRuleEvaluator, SeedingRuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
.AddScoped<IStatsService, StatsService>()
.AddSingleton<IJobManagementService, JobManagementService>()
@@ -0,0 +1,9 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore.Design;
namespace Cleanuparr.Api.DesignTime;
public sealed class DataContextFactory : IDesignTimeDbContextFactory<DataContext>
{
public DataContext CreateDbContext(string[] args) => DataContext.CreateStaticInstance();
}
@@ -0,0 +1,9 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore.Design;
namespace Cleanuparr.Api.DesignTime;
public sealed class EventsContextFactory : IDesignTimeDbContextFactory<EventsContext>
{
public EventsContext CreateDbContext(string[] args) => EventsContext.CreateStaticInstance();
}
@@ -0,0 +1,9 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore.Design;
namespace Cleanuparr.Api.DesignTime;
public sealed class UsersContextFactory : IDesignTimeDbContextFactory<UsersContext>
{
public UsersContext CreateDbContext(string[] args) => UsersContext.CreateStaticInstance();
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Extensions;
public static class ControllerBaseExtensions
{
/// <summary>
/// Builds an RFC 9457 problem-details error response for a direct (non-throwing) controller return.
/// Mirrors the shape produced by <see cref="Middleware.GlobalExceptionHandler"/> so every error
/// response carries the same <c>application/problem+json</c> body and <c>traceId</c>. The
/// <c>traceId</c> extension is added by the shared <c>CustomizeProblemDetails</c> hook inside
/// <see cref="ProblemDetailsFactory.CreateProblemDetails"/>.
/// </summary>
public static ObjectResult ProblemResult(
this ControllerBase controller,
int statusCode,
string detail,
string? title = null,
IReadOnlyDictionary<string, object?>? extensions = null)
{
ProblemDetails problemDetails = controller.ProblemDetailsFactory
.CreateProblemDetails(controller.HttpContext, statusCode: statusCode, title: title, detail: detail);
if (extensions is not null)
{
foreach (KeyValuePair<string, object?> extension in extensions)
{
problemDetails.Extensions[extension.Key] = extension.Value;
}
}
return new ObjectResult(problemDetails)
{
StatusCode = statusCode,
ContentTypes = { "application/problem+json" },
};
}
}
@@ -1,5 +1,3 @@
using Cleanuparr.Infrastructure.Extensions;
namespace Cleanuparr.Api.Extensions;
public static class HttpRequestExtensions
@@ -19,26 +17,13 @@ public static class HttpRequestExtensions
}
/// <summary>
/// Returns the external base URL (scheme + host + basePath), respecting
/// X-Forwarded-Proto and X-Forwarded-Host headers when the connection
/// originates from a local address.
/// Returns the external base URL (scheme + host + basePath).
/// TrustedForwardedHeadersMiddleware has already applied X-Forwarded-Proto and X-Forwarded-Host to <see cref="HttpRequest.Scheme"/> / <see cref="HttpRequest.Host"/>.
/// </summary>
public static string GetExternalBaseUrl(this HttpContext context)
{
var request = context.Request;
var scheme = request.Scheme;
var host = request.Host.ToString();
var remoteIp = context.Connection.RemoteIpAddress;
// Trust forwarded headers only from local connections
// (consistent with TrustedNetworkAuthenticationHandler)
if (remoteIp is not null && remoteIp.IsLocalAddress())
{
scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? scheme;
host = request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? host;
}
var basePath = request.GetSafeBasePath();
return $"{scheme}://{host}{basePath}";
return $"{request.Scheme}://{request.Host}{basePath}";
}
}
@@ -1,13 +1,15 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Shared.Helpers;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace Cleanuparr.Api.Features.Arr.Controllers;
@@ -18,15 +20,18 @@ public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly IArrClientFactory _arrClientFactory;
public ArrConfigController(
ILogger<ArrConfigController> logger,
DataContext dataContext,
EventsContext eventsContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_eventsContext = eventsContext;
_arrClientFactory = arrClientFactory;
}
@@ -159,7 +164,7 @@ public sealed class ArrConfigController : ControllerBase
.OrderBy(i => i.Name)
.ToList();
return Ok(config.Adapt<ArrConfigDto>());
return Ok(ToDto(config));
}
finally
{
@@ -182,11 +187,6 @@ public sealed class ArrConfigController : ControllerBase
return Ok(new { Message = $"{type} configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save {Type} configuration", type);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -205,12 +205,7 @@ public sealed class ArrConfigController : ControllerBase
await _dataContext.ArrInstances.AddAsync(instance);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create {Type} instance", type);
throw;
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, ToDto(instance));
}
finally
{
@@ -230,19 +225,14 @@ public sealed class ArrConfigController : ControllerBase
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
}
request.ApplyTo(instance);
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
throw;
return Ok(ToDto(instance));
}
finally
{
@@ -262,24 +252,69 @@ public sealed class ArrConfigController : ControllerBase
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
}
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
await using IDbContextTransaction transaction = await _dataContext.Database.BeginTransactionAsync();
try
{
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
await DeleteSeekerEventsForInstanceAsync(id);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task DeleteSeekerEventsForInstanceAsync(Guid arrInstanceId, CancellationToken cancellationToken = default)
{
await using IDbContextTransaction transaction = await _eventsContext.Database.BeginTransactionAsync(cancellationToken);
try
{
await _eventsContext.CustomFormatScoreEntries
.Where(e => e.ArrInstanceId == arrInstanceId)
.ExecuteDeleteAsync(cancellationToken);
await _eventsContext.CustomFormatScoreHistory
.Where(e => e.ArrInstanceId == arrInstanceId)
.ExecuteDeleteAsync(cancellationToken);
await _eventsContext.SeekerHistory
.Where(e => e.ArrInstanceId == arrInstanceId)
.ExecuteDeleteAsync(cancellationToken);
await _eventsContext.SearchQueue
.Where(e => e.ArrInstanceId == arrInstanceId)
.ExecuteDeleteAsync(cancellationToken);
await _eventsContext.SeekerCommandTrackers
.Where(e => e.ArrInstanceId == arrInstanceId)
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
{
@@ -295,7 +330,7 @@ public sealed class ArrConfigController : ControllerBase
if (existingInstance is null)
{
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Instance with ID {request.InstanceId.Value} not found");
}
resolvedApiKey = existingInstance.ApiKey;
@@ -310,10 +345,29 @@ public sealed class ArrConfigController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
}
}
private static ArrConfigDto ToDto(ArrConfig config) => new()
{
Id = config.Id,
Type = config.Type,
FailedImportMaxStrikes = config.FailedImportMaxStrikes,
Instances = config.Instances.Select(ToDto).ToList(),
};
private static ArrInstanceDto ToDto(ArrInstance instance) => new()
{
Id = instance.Id,
Enabled = instance.Enabled,
Version = instance.Version,
Name = instance.Name,
Url = instance.Url.ToString(),
ApiKey = instance.ApiKey,
ExternalUrl = instance.ExternalUrl?.ToString(),
};
private static string GetConfigActionName(InstanceType type) => type switch
{
InstanceType.Sonarr => nameof(GetSonarrConfig),
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
/// <summary>
/// Request to record that the current user has seen the given features, used to drive the "NEW" feature badges in the UI.
/// </summary>
public sealed record RecordFeatureViewsRequest
{
/// <summary>
/// The feature identifiers the user has been exposed to.
/// Unknown ids are recorded with the current timestamp; already-seen ids are ignored.
/// </summary>
[Required]
[MaxLength(100)]
public required IReadOnlyList<string> FeatureIds { get; init; }
}
@@ -0,0 +1,15 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
public sealed record FeatureViewsResponse
{
/// <summary>
/// The user's account creation timestamp, used as the anchor for "new feature" detection:
/// a feature is only considered new if it was first seen meaningfully after this point.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Map of feature id to the UTC timestamp the user first saw it.
/// </summary>
public required Dictionary<string, DateTimeOffset> Views { get; init; }
}
@@ -4,13 +4,13 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.Auth.Controllers;
@@ -67,7 +67,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Password changes are disabled while OIDC exclusive mode is active.");
}
var user = await GetCurrentUser();
@@ -78,10 +78,10 @@ public sealed class AccountController : ControllerBase
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
{
return BadRequest(new { error = "Current password is incorrect" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
}
DateTime now = DateTime.UtcNow;
DateTimeOffset now = DateTimeOffset.UtcNow;
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
user.UpdatedAt = now;
@@ -115,12 +115,12 @@ public sealed class AccountController : ControllerBase
// Verify current credentials
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
}
// Generate new TOTP
@@ -129,7 +129,7 @@ public sealed class AccountController : ControllerBase
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Replace recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -168,12 +168,12 @@ public sealed class AccountController : ControllerBase
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
// Generate new TOTP
@@ -182,7 +182,7 @@ public sealed class AccountController : ControllerBase
var recoveryCodes = _totpService.GenerateRecoveryCodes();
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Replace any existing recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -221,21 +221,21 @@ public sealed class AccountController : ControllerBase
if (user.TotpEnabled)
{
return Conflict(new { error = "2FA is already enabled" });
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return BadRequest(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid verification code");
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
@@ -254,22 +254,22 @@ public sealed class AccountController : ControllerBase
if (!user.TotpEnabled)
{
return BadRequest(new { error = "2FA is not enabled" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "2FA is not enabled");
}
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
{
return BadRequest(new { error = "Incorrect password" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
{
return BadRequest(new { error = "Invalid 2FA code" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid 2FA code");
}
user.TotpEnabled = false;
user.TotpSecret = string.Empty;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Remove all recovery codes
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -307,7 +307,7 @@ public sealed class AccountController : ControllerBase
rng.GetBytes(bytes);
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
@@ -320,7 +320,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var pin = await _plexAuthService.RequestPin();
@@ -333,7 +333,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
@@ -355,7 +355,7 @@ public sealed class AccountController : ControllerBase
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
@@ -369,7 +369,7 @@ public sealed class AccountController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
}
var user = await GetCurrentUser();
@@ -382,7 +382,7 @@ public sealed class AccountController : ControllerBase
user.PlexUsername = null;
user.PlexEmail = null;
user.PlexAuthToken = null;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
@@ -405,25 +405,18 @@ public sealed class AccountController : ControllerBase
[HttpPut("oidc")]
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
{
try
var user = await GetCurrentUser();
if (user is null)
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
catch (ValidationException ex)
{
return BadRequest(new { error = ex.Message });
return Unauthorized();
}
request.ApplyTo(user.Oidc);
user.Oidc.Validate();
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
return Ok(new { message = "OIDC configuration updated" });
}
[HttpPost("oidc/link")]
@@ -437,7 +430,7 @@ public sealed class AccountController : ControllerBase
if (user.Oidc is not { Enabled: true })
{
return BadRequest(new { error = "OIDC is not enabled" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled");
}
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
@@ -450,8 +443,7 @@ public sealed class AccountController : ControllerBase
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
return StatusCode(429, new { error = ex.Message });
throw new RateLimitException(ex.Message, ex);
}
}
@@ -504,7 +496,7 @@ public sealed class AccountController : ControllerBase
}
user.Oidc.AuthorizedSubject = result.Subject;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
@@ -527,7 +519,7 @@ public sealed class AccountController : ControllerBase
user.Oidc.AuthorizedSubject = string.Empty;
user.Oidc.ExclusiveMode = false;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
@@ -540,6 +532,79 @@ public sealed class AccountController : ControllerBase
}
}
private const int MaxFeatureIdsPerRequest = 100;
private const int MaxFeatureIdLength = 64;
/// <summary>
/// Records that the current user has seen the given features, used to drive the "NEW" feature badges in the UI.
/// Recording is idempotent: unknown ids are stamped with the current time, already-seen ids keep their original timestamp.
/// </summary>
/// <param name="request">The feature ids the user has been exposed to.</param>
/// <returns>
/// The user's account creation timestamp and the full map of feature id to first-seen timestamp.
/// </returns>
[HttpPost("feature-views")]
public async Task<IActionResult> RecordFeatureViews([FromBody] RecordFeatureViewsRequest request)
{
if (request.FeatureIds.Count > MaxFeatureIdsPerRequest)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, $"featureIds exceeds the maximum allowed ({MaxFeatureIdsPerRequest}).");
}
await UsersContext.Lock.WaitAsync();
try
{
var user = await GetCurrentUser();
if (user is null)
{
return Unauthorized();
}
var existing = await _usersContext.UserFeatureViews
.Where(v => v.UserId == user.Id)
.ToListAsync();
var existingIds = existing
.Select(v => v.FeatureId)
.ToHashSet();
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (var featureId in request.FeatureIds.Distinct())
{
if (string.IsNullOrWhiteSpace(featureId) ||
featureId.Length > MaxFeatureIdLength ||
existingIds.Contains(featureId))
{
continue;
}
var view = new UserFeatureView
{
Id = Guid.NewGuid(),
UserId = user.Id,
FeatureId = featureId,
FirstSeenAt = now
};
_usersContext.UserFeatureViews.Add(view);
existing.Add(view);
}
await _usersContext.SaveChangesAsync();
return Ok(new FeatureViewsResponse
{
CreatedAt = user.CreatedAt,
Views = existing.ToDictionary(v => v.FeatureId, v => v.FirstSeenAt)
});
}
finally
{
UsersContext.Lock.Release();
}
}
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
{
var baseUrl = string.IsNullOrEmpty(redirectUrl)
@@ -4,6 +4,7 @@ using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
using Cleanuparr.Api.Filters;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Auth;
@@ -27,6 +28,7 @@ public sealed class AuthController : ControllerBase
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AuthController> _logger;
private readonly IWebHostEnvironment _environment;
public AuthController(
UsersContext usersContext,
@@ -36,7 +38,8 @@ public sealed class AuthController : ControllerBase
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AuthController> logger)
ILogger<AuthController> logger,
IWebHostEnvironment environment)
{
_usersContext = usersContext;
_dataContext = dataContext;
@@ -46,6 +49,7 @@ public sealed class AuthController : ControllerBase
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
_environment = environment;
}
[HttpGet("status")]
@@ -57,8 +61,7 @@ public sealed class AuthController : ControllerBase
var generalConfig = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
{
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
HttpContext, generalConfig.Auth.TrustForwardedHeaders);
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(HttpContext);
if (clientIp is not null)
{
authBypass = TrustedNetworkAuthenticationHandler.IsTrustedAddress(
@@ -93,7 +96,7 @@ public sealed class AuthController : ControllerBase
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
if (existingUser is not null)
{
return Conflict(new { error = "Account already exists" });
return this.ProblemResult(StatusCodes.Status409Conflict, "Account already exists");
}
var user = new User
@@ -105,8 +108,8 @@ public sealed class AuthController : ControllerBase
TotpEnabled = false,
ApiKey = GenerateApiKey(),
SetupCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_usersContext.Users.Add(user);
@@ -134,12 +137,12 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
}
// Generate new TOTP secret
@@ -151,7 +154,7 @@ public sealed class AuthController : ControllerBase
// Store secret (will be finalized on verify)
user.TotpSecret = secret;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Remove old recovery codes and add new ones
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
@@ -191,26 +194,26 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
}
if (string.IsNullOrEmpty(user.TotpSecret))
{
return BadRequest(new { error = "Generate 2FA setup first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
}
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
{
return Unauthorized(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
}
user.TotpEnabled = true;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
@@ -232,16 +235,16 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed" });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed");
}
user.SetupCompleted = true;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Setup completed for user {Username}", user.Username);
@@ -259,7 +262,7 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
@@ -271,20 +274,21 @@ public sealed class AuthController : ControllerBase
if (user is null || !user.SetupCompleted)
{
return Unauthorized(new { error = "Invalid credentials" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials");
}
// Check lockout
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTimeOffset.UtcNow)
{
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds);
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
int remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
throw new RateLimitException("Account is locked", remaining);
}
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
{
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
int retryAfterSeconds = await IncrementFailedAttempts(user.Id);
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials",
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
}
// Reset failed attempts on successful password verification
@@ -321,13 +325,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
}
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
if (userId is null)
{
return Unauthorized(new { error = "Invalid or expired login token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired login token");
}
var user = await _usersContext.Users
@@ -336,7 +340,7 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return Unauthorized(new { error = "Invalid login token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid login token");
}
bool codeValid;
@@ -352,7 +356,7 @@ public sealed class AuthController : ControllerBase
if (!codeValid)
{
return Unauthorized(new { error = "Invalid verification code" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
}
return Ok(await GenerateTokenResponse(user));
@@ -370,13 +374,13 @@ public sealed class AuthController : ControllerBase
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
if (storedToken is null || storedToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return Unauthorized(new { error = "Invalid or expired refresh token" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired refresh token");
}
// Revoke the old token (rotation)
storedToken.RevokedAt = DateTime.UtcNow;
storedToken.RevokedAt = DateTimeOffset.UtcNow;
// Generate new tokens
var response = await GenerateTokenResponse(storedToken.User);
@@ -403,7 +407,7 @@ public sealed class AuthController : ControllerBase
if (storedToken is not null)
{
storedToken.RevokedAt = DateTime.UtcNow;
storedToken.RevokedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
}
@@ -421,12 +425,12 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
}
var pin = await _plexAuthService.RequestPin();
@@ -456,19 +460,19 @@ public sealed class AuthController : ControllerBase
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null)
{
return BadRequest(new { error = "Create an account first" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
}
user.PlexAccountId = plexAccount.AccountId;
user.PlexUsername = plexAccount.Username;
user.PlexEmail = plexAccount.Email;
user.PlexAuthToken = pinResult.AuthToken;
user.UpdatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
@@ -487,16 +491,26 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
{
return BadRequest(new { error = "Plex login is not available" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pin = await _plexAuthService.RequestPin();
string baseUrl = HttpContext.GetExternalBaseUrl();
if (_environment.IsDevelopment())
{
string origin = Request.Headers.Origin.ToString();
if (!string.IsNullOrEmpty(origin))
{
baseUrl = $"{origin}{Request.GetSafeBasePath()}";
}
}
string forwardUrl = $"{baseUrl}/auth/plex/callback";
PlexPinResult pin = await _plexAuthService.RequestPin(forwardUrl);
return Ok(new PlexPinStatusResponse
{
@@ -510,13 +524,13 @@ public sealed class AuthController : ControllerBase
{
if (await IsOidcExclusiveModeActive())
{
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
}
var user = await _usersContext.Users.FirstOrDefaultAsync();
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
{
return BadRequest(new { error = "Plex login is not available" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pinResult = await _plexAuthService.CheckPin(request.PinId);
@@ -531,7 +545,7 @@ public sealed class AuthController : ControllerBase
if (plexAccount.AccountId != user.PlexAccountId)
{
return Unauthorized(new { error = "Plex account does not match the linked account" });
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Plex account does not match the linked account");
}
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
@@ -559,7 +573,7 @@ public sealed class AuthController : ControllerBase
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
string.IsNullOrEmpty(oidcConfig.ClientId))
{
return BadRequest(new { error = "OIDC is not enabled or not configured" });
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled or not configured");
}
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
@@ -572,8 +586,7 @@ public sealed class AuthController : ControllerBase
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Failed to start OIDC authorization");
return StatusCode(429, new { error = ex.Message });
throw new RateLimitException(ex.Message, ex);
}
}
@@ -643,7 +656,7 @@ public sealed class AuthController : ControllerBase
if (result is null)
{
return NotFound(new { error = "Invalid or expired code" });
return this.ProblemResult(StatusCodes.Status404NotFound, "Invalid or expired code");
}
return Ok(new TokenResponse
@@ -672,8 +685,8 @@ public sealed class AuthController : ControllerBase
Id = Guid.NewGuid(),
UserId = user.Id,
TokenHash = HashRefreshToken(refreshToken),
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
CreatedAt = DateTimeOffset.UtcNow
});
await _usersContext.SaveChangesAsync();
@@ -696,7 +709,7 @@ public sealed class AuthController : ControllerBase
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
{
recoveryCode.IsUsed = true;
recoveryCode.UsedAt = DateTime.UtcNow;
recoveryCode.UsedAt = DateTimeOffset.UtcNow;
await _usersContext.SaveChangesAsync();
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
@@ -719,7 +732,7 @@ public sealed class AuthController : ControllerBase
{
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
user.FailedLoginAttempts++;
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
user.LockoutEnd = DateTimeOffset.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
await _usersContext.SaveChangesAsync();
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
@@ -89,11 +89,6 @@ public sealed class BlacklistSyncConfigController : ControllerBase
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -0,0 +1,14 @@
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record DeadTorrentConfigRequest
{
public bool Enabled { get; init; }
public string TargetCategory { get; init; } = "cleanuparr-dead";
public bool UseTag { get; init; }
public ushort MaxStrikes { get; init; }
public List<string> Categories { get; init; } = [];
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record OrphanedFilesConfigRequest
{
public bool Enabled { get; init; }
public List<string> ScanDirectories { get; init; } = [];
[Required]
public string OrphanedDirectory { get; init; } = string.Empty;
public List<string> ExcludePatterns { get; init; } = [];
[Range(0, int.MaxValue)]
public int MinFileAgeHours { get; init; } = 24;
[Range(1, int.MaxValue)]
public int? PurgeAfterHours { get; init; }
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record ReorderSeedingRulesRequest
{
/// <summary>
/// IDs of seeding rules in the desired priority order (first = highest priority).
/// </summary>
[Required]
public List<Guid> OrderedIds { get; init; } = [];
}
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
@@ -8,6 +8,36 @@ public record SeedingRuleRequest
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Categories this rule applies to. At least one must be specified.
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "At least one category must be specified.")]
public List<string> Categories { get; init; } = [];
/// <summary>
/// Tracker domain suffixes to match (e.g. "tracker.example.com"). Empty = any tracker.
/// </summary>
public List<string> TrackerPatterns { get; init; } = [];
/// <summary>
/// Torrent must have at least one of these tags/labels. Accepted for all clients;
/// silently ignored for Deluge, rTorrent, and µTorrent.
/// </summary>
public List<string> TagsAny { get; init; } = [];
/// <summary>
/// Torrent must have ALL of these tags/labels. Accepted for all clients;
/// silently ignored for Deluge, rTorrent, and µTorrent.
/// </summary>
public List<string> TagsAll { get; init; } = [];
/// <summary>
/// Evaluation priority (lower = evaluated first). Auto-assigned if not provided.
/// </summary>
[Range(1, int.MaxValue, ErrorMessage = "Priority must be a positive integer.")]
public int? Priority { get; init; }
/// <summary>
/// Which torrent privacy types this rule applies to.
/// </summary>
@@ -28,8 +58,16 @@ public record SeedingRuleRequest
/// </summary>
public double MaxSeedTime { get; init; } = -1;
/// <summary>
/// Minimum number of seeders required before removing a download. Set to 0 to disable.
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "Min seeders must be 0 or greater.")]
public int MinSeeders { get; init; }
public double MaxInactiveDays { get; init; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; init; } = true;
}
}
@@ -11,8 +11,4 @@ public sealed record UnlinkedConfigRequest
public List<string> IgnoredRootDirs { get; init; } = [];
public List<string> Categories { get; init; } = [];
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
}
@@ -0,0 +1,25 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record DeadTorrentConfigResponse
{
public bool Enabled { get; init; }
public required string TargetCategory { get; init; }
public bool UseTag { get; init; }
public ushort MaxStrikes { get; init; }
public required List<string> Categories { get; init; }
public static DeadTorrentConfigResponse From(DeadTorrentConfig config) => new()
{
Enabled = config.Enabled,
TargetCategory = config.TargetCategory,
UseTag = config.UseTag,
MaxStrikes = config.MaxStrikes,
Categories = config.Categories,
};
}
@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record DownloadCleanerClientResponse
{
public Guid DownloadClientId { get; init; }
public required string DownloadClientName { get; init; }
public bool DownloadClientEnabled { get; init; }
public DownloadClientTypeName DownloadClientTypeName { get; init; }
public required IReadOnlyList<SeedingRuleResponse> SeedingRules { get; init; }
public UnlinkedConfigResponse? UnlinkedConfig { get; init; }
public DeadTorrentConfigResponse? DeadTorrentConfig { get; init; }
public OrphanedFilesConfigResponse? OrphanedFilesConfig { get; init; }
}
@@ -0,0 +1,28 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record OrphanedFilesConfigResponse
{
public bool Enabled { get; init; }
public required List<string> ScanDirectories { get; init; }
public required string OrphanedDirectory { get; init; }
public required List<string> ExcludePatterns { get; init; }
public int MinFileAgeHours { get; init; }
public int? PurgeAfterHours { get; init; }
public static OrphanedFilesConfigResponse From(OrphanedFilesConfig config) => new()
{
Enabled = config.Enabled,
ScanDirectories = config.ScanDirectories,
OrphanedDirectory = config.OrphanedDirectory,
ExcludePatterns = config.ExcludePatterns,
MinFileAgeHours = config.MinFileAgeHours,
PurgeAfterHours = config.PurgeAfterHours,
};
}
@@ -0,0 +1,53 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record SeedingRuleResponse
{
public Guid Id { get; init; }
public required string Name { get; init; }
public required List<string> Categories { get; init; }
public required List<string> TrackerPatterns { get; init; }
public required List<string> TagsAny { get; init; }
public required List<string> TagsAll { get; init; }
public int Priority { get; init; }
public TorrentPrivacyType PrivacyType { get; init; }
public double MaxRatio { get; init; }
public double MinSeedTime { get; init; }
public double MaxSeedTime { get; init; }
public int? MinSeeders { get; init; }
public double? MaxInactiveDays { get; init; }
public bool DeleteSourceFiles { get; init; }
public static SeedingRuleResponse From(ISeedingRule rule) => new()
{
Id = rule.Id,
Name = rule.Name,
Categories = rule.Categories,
TrackerPatterns = rule.TrackerPatterns,
TagsAny = (rule as ITagFilterable)?.TagsAny ?? [],
TagsAll = (rule as ITagFilterable)?.TagsAll ?? [],
Priority = rule.Priority,
PrivacyType = rule.PrivacyType,
MaxRatio = rule.MaxRatio,
MinSeedTime = rule.MinSeedTime,
MaxSeedTime = rule.MaxSeedTime,
MinSeeders = (rule as ISeedersFilterable)?.MinSeeders,
MaxInactiveDays = (rule as IInactivityFilterable)?.MaxInactiveDays,
DeleteSourceFiles = rule.DeleteSourceFiles,
};
}
@@ -0,0 +1,25 @@
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
public sealed record UnlinkedConfigResponse
{
public bool Enabled { get; init; }
public required string TargetCategory { get; init; }
public bool UseTag { get; init; }
public required List<string> IgnoredRootDirs { get; init; }
public required List<string> Categories { get; init; }
public static UnlinkedConfigResponse From(UnlinkedConfig config) => new()
{
Enabled = config.Enabled,
TargetCategory = config.TargetCategory,
UseTag = config.UseTag,
IgnoredRootDirs = config.IgnoredRootDirs,
Categories = config.Categories,
};
}
@@ -0,0 +1,108 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/dead-torrent-config")]
[Authorize]
public class DeadTorrentConfigController : ControllerBase
{
private readonly ILogger<DeadTorrentConfigController> _logger;
private readonly DataContext _dataContext;
public DeadTorrentConfigController(
ILogger<DeadTorrentConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetDeadTorrentConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.DeadTorrentConfigs
.AsNoTracking()
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
return Ok(config is null ? null : DeadTorrentConfigResponse.From(config));
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateDeadTorrentConfig(Guid downloadClientId, [FromBody] DeadTorrentConfigRequest dto)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
if (dto.Enabled && client.TypeName is DownloadClientTypeName.rTorrent)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, "Dead torrent handling is not supported for rTorrent (no seeder count available)");
}
var existing = await _dataContext.DeadTorrentConfigs
.FirstOrDefaultAsync(d => d.DownloadClientConfigId == downloadClientId);
if (existing is null)
{
existing = new DeadTorrentConfig
{
DownloadClientConfigId = downloadClientId,
};
_dataContext.DeadTorrentConfigs.Add(existing);
}
existing.Enabled = dto.Enabled;
existing.TargetCategory = dto.TargetCategory;
existing.UseTag = dto.UseTag;
existing.MaxStrikes = dto.MaxStrikes;
existing.Categories = dto.Categories;
existing.Validate();
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated dead torrent config for client {ClientId}", downloadClientId);
return Ok(DeadTorrentConfigResponse.From(existing));
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
@@ -52,44 +53,40 @@ public sealed class DownloadCleanerConfigController : ControllerBase
var allTransmissionRules = await _dataContext.TransmissionSeedingRules.AsNoTracking().ToListAsync();
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
var allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
List<UnlinkedConfig> allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
List<DeadTorrentConfig> allDeadTorrentConfigs = await _dataContext.DeadTorrentConfigs.AsNoTracking().ToListAsync();
List<OrphanedFilesConfig> allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync();
var clients = new List<object>();
Dictionary<Guid, UnlinkedConfig> unlinkedConfigsByClientId = allUnlinkedConfigs
.GroupBy(u => u.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
Dictionary<Guid, DeadTorrentConfig> deadTorrentConfigsByClientId = allDeadTorrentConfigs
.GroupBy(d => d.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
Dictionary<Guid, OrphanedFilesConfig> orphanedFilesConfigsByClientId = allOrphanedFilesConfigs
.GroupBy(o => o.DownloadClientConfigId)
.ToDictionary(g => g.Key, g => g.First());
var clients = new List<DownloadCleanerClientResponse>();
foreach (var client in downloadClients)
{
var seedingRules = SeedingRuleHelper.FilterForClient(
client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
var unlinkedConfig = allUnlinkedConfigs.FirstOrDefault(u => u.DownloadClientConfigId == client.Id);
List<ISeedingRule> seedingRules = SeedingRuleHelper
.FilterForClient(client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
unlinkedConfigsByClientId.TryGetValue(client.Id, out UnlinkedConfig? unlinkedConfig);
deadTorrentConfigsByClientId.TryGetValue(client.Id, out DeadTorrentConfig? deadTorrentConfig);
orphanedFilesConfigsByClientId.TryGetValue(client.Id, out OrphanedFilesConfig? orphanedFilesConfig);
clients.Add(new
clients.Add(new DownloadCleanerClientResponse
{
downloadClientId = client.Id,
downloadClientName = client.Name,
downloadClientEnabled = client.Enabled,
downloadClientTypeName = client.TypeName,
seedingRules = seedingRules.Select(r => new
{
id = r.Id,
name = r.Name,
privacyType = r.PrivacyType,
maxRatio = r.MaxRatio,
minSeedTime = r.MinSeedTime,
maxSeedTime = r.MaxSeedTime,
deleteSourceFiles = r.DeleteSourceFiles,
}),
unlinkedConfig = unlinkedConfig is not null
? new
{
enabled = unlinkedConfig.Enabled,
targetCategory = unlinkedConfig.TargetCategory,
useTag = unlinkedConfig.UseTag,
ignoredRootDirs = unlinkedConfig.IgnoredRootDirs,
categories = unlinkedConfig.Categories,
downloadDirectorySource = unlinkedConfig.DownloadDirectorySource,
downloadDirectoryTarget = unlinkedConfig.DownloadDirectoryTarget,
}
: null,
DownloadClientId = client.Id,
DownloadClientName = client.Name,
DownloadClientEnabled = client.Enabled,
DownloadClientTypeName = client.TypeName,
SeedingRules = seedingRules.Select(SeedingRuleResponse.From).ToList(),
UnlinkedConfig = unlinkedConfig is not null ? UnlinkedConfigResponse.From(unlinkedConfig) : null,
DeadTorrentConfig = deadTorrentConfig is not null ? DeadTorrentConfigResponse.From(deadTorrentConfig) : null,
OrphanedFilesConfig = orphanedFilesConfig is not null ? OrphanedFilesConfigResponse.From(orphanedFilesConfig) : null,
});
}
@@ -139,15 +136,6 @@ public sealed class DownloadCleanerConfigController : ControllerBase
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -0,0 +1,120 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/orphaned-files-config")]
[Authorize]
public sealed class OrphanedFilesConfigController : ControllerBase
{
private readonly ILogger<OrphanedFilesConfigController> _logger;
private readonly DataContext _dataContext;
public OrphanedFilesConfigController(
ILogger<OrphanedFilesConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetClientConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
return Ok(config is null ? null : OrphanedFilesConfigResponse.From(config));
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateClientConfig(Guid downloadClientId, [FromBody] OrphanedFilesConfigRequest dto)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existing = await _dataContext.OrphanedFilesConfigs
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
var candidate = (existing ?? new OrphanedFilesConfig { DownloadClientConfigId = downloadClientId }) with
{
Enabled = dto.Enabled,
ScanDirectories = dto.ScanDirectories,
OrphanedDirectory = dto.OrphanedDirectory,
ExcludePatterns = dto.ExcludePatterns,
MinFileAgeHours = dto.MinFileAgeHours,
PurgeAfterHours = dto.PurgeAfterHours,
};
var siblings = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.Where(c => c.DownloadClientConfigId != downloadClientId)
.ToListAsync();
var otherDownloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.Where(c => c.Id != downloadClientId)
.ToListAsync();
candidate.Validate(siblings, otherDownloadClients);
if (existing is null)
{
_dataContext.OrphanedFilesConfigs.Add(candidate);
}
else
{
existing.Enabled = candidate.Enabled;
existing.ScanDirectories = candidate.ScanDirectories;
existing.OrphanedDirectory = candidate.OrphanedDirectory;
existing.ExcludePatterns = candidate.ExcludePatterns;
existing.MinFileAgeHours = candidate.MinFileAgeHours;
existing.PurgeAfterHours = candidate.PurgeAfterHours;
}
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated orphaned files client config for client {ClientId}", downloadClientId);
return Ok(OrphanedFilesConfigResponse.From(existing ?? candidate));
}
finally
{
DataContext.Lock.Release();
}
}
}
@@ -1,4 +1,6 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -7,7 +9,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
@@ -39,17 +40,12 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var rules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve seeding rules for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve seeding rules", Error = ex.Message });
return Ok(rules.Select(SeedingRuleResponse.From));
}
finally
{
@@ -60,11 +56,6 @@ public class SeedingRulesController : ControllerBase
[HttpPost("{downloadClientId}")]
public async Task<IActionResult> CreateSeedingRule(Guid downloadClientId, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -74,26 +65,19 @@ public class SeedingRulesController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existingRules = await SeedingRuleHelper.GetForClientAsync(_dataContext, client);
var duplicate = existingRules.FirstOrDefault(r =>
r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == ruleDto.PrivacyType);
if (duplicate is not null)
if (ruleDto.Priority.HasValue && existingRules.Any(r => r.Priority == ruleDto.Priority.Value))
{
return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"A seeding rule with priority {ruleDto.Priority.Value} already exists for this client");
}
var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, existingRules, excludeId: null);
if (overlapError is not null)
{
return BadRequest(new { Message = overlapError });
}
int priority = ruleDto.Priority ?? (existingRules.Count == 0 ? 1 : existingRules.Max(r => r.Priority) + 1);
var rule = CreateRule(client.TypeName, client.Id, ruleDto);
var rule = CreateRule(client.TypeName, client.Id, ruleDto, priority);
rule.Validate();
AddRuleToDbSet(rule);
@@ -104,17 +88,6 @@ public class SeedingRulesController : ControllerBase
return CreatedAtAction(nameof(GetSeedingRules), new { downloadClientId }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for seeding rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create seeding rule: {RuleName} for client {ClientId}",
ruleDto.Name, downloadClientId);
return StatusCode(500, new { Message = "Failed to create seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -124,11 +97,6 @@ public class SeedingRulesController : ControllerBase
[HttpPut("{id}")]
public async Task<IActionResult> UpdateSeedingRule(Guid id, [FromBody] SeedingRuleRequest ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -136,33 +104,34 @@ public class SeedingRulesController : ControllerBase
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
}
// Check for duplicate name+privacyType on the same client, excluding this rule
var clientRules = await SeedingRuleHelper.GetForClientIdAsync(_dataContext, existingRule.DownloadClientConfigId);
var duplicate = clientRules.FirstOrDefault(r =>
r.Id != id &&
r.Name.Equals(ruleDto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == ruleDto.PrivacyType);
if (duplicate is not null)
{
return BadRequest(new { Message = "A seeding rule with this name and privacy type already exists for this client" });
}
var overlapError = GetPrivacyTypeOverlapError(ruleDto.Name.Trim(), ruleDto.PrivacyType, clientRules, excludeId: id);
if (overlapError is not null)
{
return BadRequest(new { Message = overlapError });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeding rule with ID {id} not found");
}
existingRule.Name = ruleDto.Name.Trim();
existingRule.Categories = SanitizeStringList(ruleDto.Categories);
existingRule.TrackerPatterns = SanitizeStringList(ruleDto.TrackerPatterns);
existingRule.PrivacyType = ruleDto.PrivacyType;
existingRule.MaxRatio = ruleDto.MaxRatio;
existingRule.MinSeedTime = ruleDto.MinSeedTime;
existingRule.MaxSeedTime = ruleDto.MaxSeedTime;
existingRule.DeleteSourceFiles = ruleDto.DeleteSourceFiles;
// Priority is intentionally NOT updated here — use the reorder endpoint
if (existingRule is ITagFilterable tagFilterable)
{
tagFilterable.TagsAny = SanitizeStringList(ruleDto.TagsAny);
tagFilterable.TagsAll = SanitizeStringList(ruleDto.TagsAll);
}
if (existingRule is ISeedersFilterable seedersFilterable)
{
seedersFilterable.MinSeeders = ruleDto.MinSeeders;
}
if (existingRule is IInactivityFilterable inactivityFilterable)
{
inactivityFilterable.MaxInactiveDays = ruleDto.MaxInactiveDays;
}
existingRule.Validate();
@@ -172,15 +141,57 @@ public class SeedingRulesController : ControllerBase
return Ok(existingRule);
}
catch (ValidationException ex)
finally
{
_logger.LogWarning("Validation failed for seeding rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
DataContext.Lock.Release();
}
catch (Exception ex)
}
[HttpPut("{downloadClientId}/reorder")]
public async Task<IActionResult> ReorderSeedingRules(Guid downloadClientId, [FromBody] ReorderSeedingRulesRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
_logger.LogError(ex, "Failed to update seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update seeding rule", Error = ex.Message });
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
List<ISeedingRule> rules = await SeedingRuleHelper.GetForClientTrackedAsync(_dataContext, client);
if (request.OrderedIds.Distinct().Count() != request.OrderedIds.Count)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, "Duplicate rule IDs are not allowed");
}
if (request.OrderedIds.Count != rules.Count)
{
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Expected {rules.Count} rule IDs but received {request.OrderedIds.Count}. All rules must be included.");
}
foreach (Guid id in request.OrderedIds.Where(id => rules.All(r => r.Id != id)))
{
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Rule with ID {id} not found for client {downloadClientId}");
}
int priority = 1;
var lookup = rules.ToDictionary(r => r.Id);
foreach (var id in request.OrderedIds)
{
lookup[id].Priority = priority++;
}
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Reordered {Count} seeding rules for client {ClientId}", rules.Count, downloadClientId);
return NoContent();
}
finally
{
@@ -198,7 +209,7 @@ public class SeedingRulesController : ControllerBase
if (existingRule is null)
{
return NotFound(new { Message = $"Seeding rule with ID {id} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeding rule with ID {id} not found");
}
RemoveRuleFromDbSet(existingRule);
@@ -208,95 +219,92 @@ public class SeedingRulesController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete seeding rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete seeding rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
private static string? GetPrivacyTypeOverlapError(
string name,
TorrentPrivacyType privacyType,
IEnumerable<ISeedingRule> existingRules,
Guid? excludeId)
private static List<string> SanitizeStringList(List<string> list)
=> list.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToList();
private static ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto, int priority)
{
if (privacyType == TorrentPrivacyType.Both)
{
var hasConflict = existingRules.Any(r =>
r.Id != excludeId &&
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType != TorrentPrivacyType.Both);
var categories = SanitizeStringList(dto.Categories);
var trackerPatterns = SanitizeStringList(dto.TrackerPatterns);
var tagsAny = SanitizeStringList(dto.TagsAny);
var tagsAll = SanitizeStringList(dto.TagsAll);
return hasConflict
? "A 'Both' rule cannot coexist with a Public or Private rule for the same category"
: null;
}
else
{
var hasConflict = existingRules.Any(r =>
r.Id != excludeId &&
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) &&
r.PrivacyType == TorrentPrivacyType.Both);
return hasConflict
? "A Public or Private rule cannot coexist with a 'Both' rule for the same category"
: null;
}
}
private ISeedingRule CreateRule(DownloadClientTypeName typeName, Guid clientId, SeedingRuleRequest dto)
{
return typeName switch
{
DownloadClientTypeName.qBittorrent => new QBitSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
TagsAny = tagsAny,
TagsAll = tagsAll,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
MaxInactiveDays = dto.MaxInactiveDays,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.Deluge => new DelugeSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.Transmission => new TransmissionSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
TagsAny = tagsAny,
TagsAll = tagsAll,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.uTorrent => new UTorrentSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
MaxSeedTime = dto.MaxSeedTime,
MinSeeders = dto.MinSeeders,
DeleteSourceFiles = dto.DeleteSourceFiles,
},
DownloadClientTypeName.rTorrent => new RTorrentSeedingRule
{
DownloadClientConfigId = clientId,
Name = dto.Name.Trim(),
Categories = categories,
TrackerPatterns = trackerPatterns,
Priority = priority,
PrivacyType = dto.PrivacyType,
MaxRatio = dto.MaxRatio,
MinSeedTime = dto.MinSeedTime,
@@ -350,5 +358,4 @@ public class SeedingRulesController : ControllerBase
break;
}
}
}
@@ -1,11 +1,12 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
@@ -37,19 +38,14 @@ public class UnlinkedConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var config = await _dataContext.UnlinkedConfigs
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadClientId);
return Ok(config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve unlinked config", Error = ex.Message });
return Ok(config is null ? null : UnlinkedConfigResponse.From(config));
}
finally
{
@@ -60,11 +56,6 @@ public class UnlinkedConfigController : ControllerBase
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateUnlinkedConfig(Guid downloadClientId, [FromBody] UnlinkedConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
@@ -74,7 +65,7 @@ public class UnlinkedConfigController : ControllerBase
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {downloadClientId} not found");
}
var existing = await _dataContext.UnlinkedConfigs
@@ -94,8 +85,6 @@ public class UnlinkedConfigController : ControllerBase
existing.UseTag = dto.UseTag;
existing.IgnoredRootDirs = dto.IgnoredRootDirs;
existing.Categories = dto.Categories;
existing.DownloadDirectorySource = dto.DownloadDirectorySource;
existing.DownloadDirectoryTarget = dto.DownloadDirectoryTarget;
existing.Validate();
@@ -103,17 +92,7 @@ public class UnlinkedConfigController : ControllerBase
_logger.LogInformation("Updated unlinked config for client {ClientId}", downloadClientId);
return Ok(existing);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for unlinked config update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to update unlinked config", Error = ex.Message });
return Ok(UnlinkedConfigResponse.From(existing));
}
finally
{
@@ -16,15 +16,57 @@ internal static class SeedingRuleHelper
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => [],
};
}
/// <summary>
/// Queries the appropriate per-type seeding rules table for a single client with change tracking enabled.
/// Use this when you need to modify and save the returned entities.
/// </summary>
public static async Task<List<ISeedingRule>> GetForClientTrackedAsync(DataContext ctx, DownloadClientConfig client)
{
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await ctx.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await ctx.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await ctx.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await ctx.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await ctx.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => [],
};
}
@@ -55,11 +97,26 @@ internal static class SeedingRuleHelper
{
return client.TypeName switch
{
DownloadClientTypeName.qBittorrent => qbitRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => delugeRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => transmissionRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => utorrentRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => rtorrentRules.Where(r => r.DownloadClientConfigId == client.Id).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.qBittorrent => qbitRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => delugeRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => transmissionRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => utorrentRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => rtorrentRules
.Where(r => r.DownloadClientConfigId == client.Id)
.OrderBy(r => r.Priority).ThenBy(r => r.Id)
.Cast<ISeedingRule>().ToList(),
_ => [],
};
}
@@ -27,6 +27,10 @@ public sealed record CreateDownloadClientRequest
public string? ExternalUrl { get; init; }
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -66,5 +70,7 @@ public sealed record CreateDownloadClientRequest
Password = Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
DownloadDirectorySource = DownloadDirectorySource,
DownloadDirectoryTarget = DownloadDirectoryTarget,
};
}
@@ -27,6 +27,10 @@ public sealed record UpdateDownloadClientRequest
public string? ExternalUrl { get; init; }
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -61,5 +65,7 @@ public sealed record UpdateDownloadClientRequest
Password = Password.IsPlaceholder() ? existing.Password : Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
DownloadDirectorySource = DownloadDirectorySource,
DownloadDirectoryTarget = DownloadDirectoryTarget,
};
}
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
@@ -66,17 +67,13 @@ public sealed class DownloadClientController : ControllerBase
newClient.Validate();
var clientConfig = newClient.ToEntity();
clientConfig.Validate();
_dataContext.DownloadClients.Add(clientConfig);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download client");
throw;
}
finally
{
DataContext.Lock.Release();
@@ -96,21 +93,17 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
clientToPersist.Validate();
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
await _dataContext.SaveChangesAsync();
return Ok(clientToPersist);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -128,7 +121,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {id} not found");
}
_dataContext.DownloadClients.Remove(existingClient);
@@ -141,11 +134,6 @@ public sealed class DownloadClientController : ControllerBase
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
@@ -169,7 +157,7 @@ public sealed class DownloadClientController : ControllerBase
if (existingClient is null)
{
return NotFound($"Download client with ID {request.ClientId.Value} not found");
return this.ProblemResult(StatusCodes.Status404NotFound, $"Download client with ID {request.ClientId.Value} not found");
}
resolvedPassword = existingClient.Password;
@@ -188,12 +176,12 @@ public sealed class DownloadClientController : ControllerBase
});
}
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
return this.ProblemResult(StatusCodes.Status400BadRequest, healthResult.ErrorMessage ?? "Connection failed");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
}
}
}
Loaded 100 of 1098 files, more files were not shown because too many files have changed in this diff. Show more