mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 03:47:42 -04:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5718f9219 | ||
|
|
46ef402b82 | ||
|
|
895dc4517b | ||
|
|
32a4c5da5c | ||
|
|
c4b3adcf87 | ||
|
|
075461391b | ||
|
|
2c1346f95f | ||
|
|
e20fa1339d | ||
|
|
4dc8b5e051 | ||
|
|
dcdc07610a | ||
|
|
6a6a7a79a7 | ||
|
|
282ec1d325 | ||
|
|
a28caf1bc9 | ||
|
|
1fbfcc0803 | ||
|
|
5c5a217430 | ||
|
|
ccdbb6294a | ||
|
|
107b0e7579 | ||
|
|
4884f1f297 | ||
|
|
74ec19ae48 | ||
|
|
6ae7592f6c | ||
|
|
3089b61a7a | ||
|
|
18da58c346 | ||
|
|
4d852911a5 | ||
|
|
71113d8838 | ||
|
|
c4cddf8897 | ||
|
|
d7bb3195de | ||
|
|
569cac9d61 | ||
|
|
96844ac89c | ||
|
|
9642ea263d | ||
|
|
d34ddb841d | ||
|
|
65afcaf3a4 | ||
|
|
d784091ee4 | ||
|
|
81cd7583cd | ||
|
|
3f6c3321e9 | ||
|
|
be3db1616e | ||
|
|
9fd99bd5f5 | ||
|
|
8f1ea44466 | ||
|
|
6ec19a92b2 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -8,6 +8,24 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
platforms:
|
||||
description: 'JSON array of runtime/platform pairs to build. Defaults to all platforms.'
|
||||
type: string
|
||||
required: false
|
||||
default: >-
|
||||
[{"runtime":"win-x64","platform":"win-amd64"},
|
||||
{"runtime":"linux-x64","platform":"linux-amd64"},
|
||||
{"runtime":"linux-arm64","platform":"linux-arm64"},
|
||||
{"runtime":"osx-x64","platform":"osx-amd64"},
|
||||
{"runtime":"osx-arm64","platform":"osx-arm64"}]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
@@ -16,17 +34,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- runtime: win-x64
|
||||
platform: win-amd64
|
||||
- runtime: linux-x64
|
||||
platform: linux-amd64
|
||||
- runtime: linux-arm64
|
||||
platform: linux-arm64
|
||||
- runtime: osx-x64
|
||||
platform: osx-amd64
|
||||
- runtime: osx-arm64
|
||||
platform: osx-arm64
|
||||
include: ${{ fromJSON(inputs.platforms) }}
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
@@ -55,7 +63,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 +74,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 }}
|
||||
ref: ${{ inputs.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 +96,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 +128,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,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
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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,6 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -40,11 +41,13 @@ jobs:
|
||||
|
||||
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
|
||||
@@ -61,9 +64,9 @@ jobs:
|
||||
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
|
||||
@@ -71,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: |
|
||||
@@ -90,7 +94,7 @@ jobs:
|
||||
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-${{ matrix.suite.name }}
|
||||
|
||||
@@ -4,11 +4,8 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
@@ -18,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request != null
|
||||
outputs:
|
||||
build_windows: ${{ steps.parse.outputs.build_windows }}
|
||||
build: ${{ steps.parse.outputs.build }}
|
||||
pr_ref: ${{ steps.parse.outputs.pr_ref }}
|
||||
pr_sha: ${{ steps.parse.outputs.pr_sha }}
|
||||
pr_number: ${{ steps.parse.outputs.pr_number }}
|
||||
@@ -26,20 +23,20 @@ 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();
|
||||
|
||||
// Parse supported commands
|
||||
const commands = {
|
||||
'/build-windows': 'build_windows'
|
||||
'/build-windows': 'windows',
|
||||
'/build-linux': 'linux'
|
||||
};
|
||||
|
||||
const command = commands[comment];
|
||||
if (!command) {
|
||||
const target = commands[comment];
|
||||
if (!target) {
|
||||
console.log(`Comment "${comment}" is not a recognized build command, skipping.`);
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,7 +61,6 @@ jobs:
|
||||
// Verify PR is open
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +74,6 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
body: 'On-demand builds are not available for PRs from forks.'
|
||||
});
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,12 +96,11 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
body: `@${context.payload.comment.user.login} Only collaborators with write access can trigger builds.`
|
||||
});
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`);
|
||||
core.setOutput(command, 'true');
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${comment}.`);
|
||||
core.setOutput('build', target);
|
||||
|
||||
// Export PR details for downstream jobs
|
||||
core.setOutput('pr_ref', pr.data.head.ref);
|
||||
@@ -115,33 +109,65 @@ jobs:
|
||||
|
||||
build-frontend:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
if: needs.validate.outputs.build != ''
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-windows:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
if: needs.validate.outputs.build == 'windows'
|
||||
concurrency:
|
||||
group: pr-build-windows-${{ needs.validate.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-linux:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build == 'linux'
|
||||
concurrency:
|
||||
group: pr-build-linux-${{ needs.validate.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
platforms: '[{"runtime":"linux-x64","platform":"linux-amd64"},{"runtime":"linux-arm64","platform":"linux-arm64"}]'
|
||||
secrets: inherit
|
||||
|
||||
post-result:
|
||||
needs: [validate, build-windows]
|
||||
if: always() && needs.validate.outputs.build_windows == 'true'
|
||||
needs: [validate, build-windows, build-linux]
|
||||
if: always() && needs.validate.outputs.build != ''
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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 target = '${{ needs.validate.outputs.build }}';
|
||||
const builds = {
|
||||
windows: {
|
||||
label: 'Windows installer',
|
||||
result: '${{ needs.build-windows.result }}',
|
||||
artifacts: '`Cleanuparr-windows-installer`'
|
||||
},
|
||||
linux: {
|
||||
label: 'Linux executables',
|
||||
result: '${{ needs.build-linux.result }}',
|
||||
artifacts: '`executable-linux-amd64` and `executable-linux-arm64`'
|
||||
}
|
||||
};
|
||||
|
||||
const build = builds[target];
|
||||
const buildResult = build.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);
|
||||
|
||||
@@ -154,20 +180,20 @@ jobs:
|
||||
let body;
|
||||
if (buildResult === 'success') {
|
||||
body = [
|
||||
`Windows installer build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
`${build.label} build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`,
|
||||
`The artifact \`Cleanuparr-windows-installer\` is retained for 30 days.`
|
||||
`The ${build.artifacts} artifact(s) are retained for 30 days.`
|
||||
].join('\n');
|
||||
} else if (buildResult === 'cancelled') {
|
||||
body = [
|
||||
`Windows installer build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
`${build.label} build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
} else {
|
||||
body = [
|
||||
`Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
`${build.label} build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -27,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/)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
};
|
||||
}
|
||||
+174
@@ -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");
|
||||
}
|
||||
}
|
||||
+128
@@ -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");
|
||||
}
|
||||
}
|
||||
+28
@@ -45,6 +45,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0,
|
||||
double maxInactiveDays = -1,
|
||||
bool deleteSourceFiles = true)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
@@ -60,6 +61,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
MaxInactiveDays = maxInactiveDays,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
};
|
||||
}
|
||||
@@ -196,6 +198,17 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
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()
|
||||
{
|
||||
@@ -330,6 +343,21 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
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()
|
||||
{
|
||||
|
||||
+200
@@ -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;
|
||||
}
|
||||
}
|
||||
+275
@@ -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,
|
||||
};
|
||||
}
|
||||
+151
@@ -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");
|
||||
}
|
||||
}
|
||||
+156
@@ -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",
|
||||
};
|
||||
}
|
||||
+9
-5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -495,7 +499,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
bool isMonitored = true,
|
||||
string qualityProfileName = "HD")
|
||||
{
|
||||
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
@@ -509,7 +513,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
@@ -521,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,
|
||||
@@ -532,7 +536,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -279,21 +279,16 @@ public class SearchStatsControllerTests : IDisposable
|
||||
Timestamp = timestamp ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
|
||||
if (itemTitle is not null)
|
||||
{
|
||||
_eventsContext.SearchEventData.Add(new SearchEventData
|
||||
{
|
||||
AppEventId = appEvent.Id,
|
||||
ItemTitle = itemTitle,
|
||||
SearchType = searchType,
|
||||
SearchReason = searchReason,
|
||||
GrabbedItems = grabbedItems ?? [],
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
appEvent.ItemTitle = itemTitle;
|
||||
appEvent.SearchType = searchType;
|
||||
appEvent.SearchReason = searchReason;
|
||||
appEvent.GrabbedItems = grabbedItems ?? [];
|
||||
}
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -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 =
|
||||
[
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
using Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
using Cleanuparr.Api.Features.Webhooks.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Webhooks;
|
||||
|
||||
public class WebhooksControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagement;
|
||||
private readonly WebhooksController _controller;
|
||||
|
||||
private Guid _sonarrInstanceId;
|
||||
private Guid _lidarrInstanceId;
|
||||
|
||||
public WebhooksControllerTests()
|
||||
{
|
||||
_dataContext = CreateDataContext();
|
||||
_jobManagement = Substitute.For<IJobManagementService>();
|
||||
var logger = Substitute.For<ILogger<WebhooksController>>();
|
||||
_controller = new WebhooksController(logger, _dataContext, _jobManagement);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
var options = new DbContextOptionsBuilder<DataContext>().UseSqlite(connection).Options;
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
var sonarrInstance = new ArrInstance { Enabled = true, Name = "Sonarr", Url = new Uri("http://sonarr:8989"), ApiKey = "key" };
|
||||
var lidarrInstance = new ArrInstance { Enabled = true, Name = "Lidarr", Url = new Uri("http://lidarr:8686"), ApiKey = "key" };
|
||||
_sonarrInstanceId = sonarrInstance.Id;
|
||||
_lidarrInstanceId = lidarrInstance.Id;
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Type = InstanceType.Sonarr, Instances = [sonarrInstance] },
|
||||
new ArrConfig { Type = InstanceType.Lidarr, Instances = [lidarrInstance] }
|
||||
);
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TriggerMode = JobTriggerMode.Both,
|
||||
IgnoredDownloads = [],
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
return context;
|
||||
}
|
||||
|
||||
private void SetConfig(bool enabled, JobTriggerMode mode)
|
||||
{
|
||||
var config = _dataContext.ContentBlockerConfigs.First();
|
||||
config.Enabled = enabled;
|
||||
config.TriggerMode = mode;
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private static ArrWebhookPayload GrabPayload(string? downloadId = "HASH123", long seriesId = 42) => new()
|
||||
{
|
||||
EventType = "Grab",
|
||||
DownloadId = downloadId,
|
||||
Series = new ArrWebhookContent { Id = seriesId },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task TestEvent_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, new ArrWebhookPayload { EventType = "Test" });
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidGrab_SchedulesTargetedScan()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.Received(1)
|
||||
.TriggerMalwareBlockerWebhook(_sonarrInstanceId, "HASH123", 42, InstanceType.Sonarr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownInstance_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(Guid.NewGuid(), GrabPayload());
|
||||
|
||||
var notFound = result.ShouldBeOfType<ObjectResult>();
|
||||
notFound.StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonSonarrRadarrInstance_ReturnsUnprocessable()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_lidarrInstanceId, GrabPayload());
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<ObjectResult>();
|
||||
unprocessable.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
SetConfig(enabled: false, JobTriggerMode.Both);
|
||||
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScheduleOnlyMode_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
SetConfig(enabled: true, JobTriggerMode.Schedule);
|
||||
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloadId_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload(downloadId: null));
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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();
|
||||
}
|
||||
}
|
||||
@@ -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,17 +18,19 @@ 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 = 50,
|
||||
[FromQuery] string? severity = null,
|
||||
@@ -49,35 +55,40 @@ public class EventsController : ControllerBase
|
||||
{
|
||||
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,29 +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.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,
|
||||
@@ -117,7 +128,7 @@ public class EventsController : ControllerBase
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages
|
||||
};
|
||||
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -128,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();
|
||||
|
||||
@@ -149,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 = DateTimeOffset.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
await _context.Events
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique event types
|
||||
/// </summary>
|
||||
@@ -183,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.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
@@ -78,8 +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.Message.ToLower(), pattern, "\\") ||
|
||||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,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>
|
||||
@@ -175,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 = DateTimeOffset.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
var deletedCount = await _context.ManualEvents
|
||||
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok(new { DeletedCount = deletedCount });
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,16 @@ 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 static readonly DateTimeOffset SunsetDate = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IStatsService _statsService;
|
||||
|
||||
public StatsController(IStatsService statsService)
|
||||
@@ -20,7 +23,8 @@ public class StatsController : ControllerBase
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -31,6 +35,17 @@ public class StatsController : ControllerBase
|
||||
[FromQuery] int includeEvents = 0,
|
||||
[FromQuery] int includeStrikes = 0)
|
||||
{
|
||||
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\"";
|
||||
|
||||
if (DateTimeOffset.UtcNow >= SunsetDate)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
hours = Math.Clamp(hours, 1, 720);
|
||||
includeEvents = Math.Clamp(includeEvents, 0, 100);
|
||||
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -60,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();
|
||||
@@ -170,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 DateTimeOffset LatestStrikeAt { get; set; }
|
||||
public DateTimeOffset 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 DateTimeOffset 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 DateTimeOffset CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
@@ -8,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;
|
||||
@@ -16,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 =>
|
||||
@@ -32,26 +24,13 @@ 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>();
|
||||
@@ -112,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;
|
||||
@@ -40,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;
|
||||
});
|
||||
|
||||
@@ -18,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;
|
||||
|
||||
@@ -28,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>()
|
||||
@@ -62,6 +64,7 @@ public static class ServicesDI
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<IConnectivityChecker, ConnectivityChecker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IQueueRuleManager, QueueRuleManager>()
|
||||
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -4,11 +4,12 @@ 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;
|
||||
|
||||
@@ -19,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;
|
||||
}
|
||||
|
||||
@@ -160,7 +164,7 @@ public sealed class ArrConfigController : ControllerBase
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
return Ok(ToDto(config));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -201,7 +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>());
|
||||
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, ToDto(instance));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -228,7 +232,7 @@ public sealed class ArrConfigController : ControllerBase
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||
return Ok(ToDto(instance));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -251,9 +255,23 @@ public sealed class ArrConfigController : ControllerBase
|
||||
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();
|
||||
}
|
||||
finally
|
||||
@@ -261,6 +279,42 @@ public sealed class ArrConfigController : ControllerBase
|
||||
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,6 +349,25 @@ public sealed class ArrConfigController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -28,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,
|
||||
@@ -37,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;
|
||||
@@ -47,6 +49,7 @@ public sealed class AuthController : ControllerBase
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -497,7 +500,17 @@ public sealed class AuthController : ControllerBase
|
||||
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
|
||||
{
|
||||
|
||||
+2
@@ -64,6 +64,8 @@ public record SeedingRuleRequest
|
||||
[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>
|
||||
|
||||
+3
@@ -29,6 +29,8 @@ public sealed record SeedingRuleResponse
|
||||
|
||||
public int? MinSeeders { get; init; }
|
||||
|
||||
public double? MaxInactiveDays { get; init; }
|
||||
|
||||
public bool DeleteSourceFiles { get; init; }
|
||||
|
||||
public static SeedingRuleResponse From(ISeedingRule rule) => new()
|
||||
@@ -45,6 +47,7 @@ public sealed record SeedingRuleResponse
|
||||
MinSeedTime = rule.MinSeedTime,
|
||||
MaxSeedTime = rule.MaxSeedTime,
|
||||
MinSeeders = (rule as ISeedersFilterable)?.MinSeeders,
|
||||
MaxInactiveDays = (rule as IInactivityFilterable)?.MaxInactiveDays,
|
||||
DeleteSourceFiles = rule.DeleteSourceFiles,
|
||||
};
|
||||
}
|
||||
+6
@@ -128,6 +128,11 @@ public class SeedingRulesController : ControllerBase
|
||||
seedersFilterable.MinSeeders = ruleDto.MinSeeders;
|
||||
}
|
||||
|
||||
if (existingRule is IInactivityFilterable inactivityFilterable)
|
||||
{
|
||||
inactivityFilterable.MaxInactiveDays = ruleDto.MaxInactiveDays;
|
||||
}
|
||||
|
||||
existingRule.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
@@ -246,6 +251,7 @@ public class SeedingRulesController : ControllerBase
|
||||
MinSeedTime = dto.MinSeedTime,
|
||||
MaxSeedTime = dto.MaxSeedTime,
|
||||
MinSeeders = dto.MinSeeders,
|
||||
MaxInactiveDays = dto.MaxInactiveDays,
|
||||
DeleteSourceFiles = dto.DeleteSourceFiles,
|
||||
},
|
||||
DownloadClientTypeName.Deluge => new DelugeSeedingRule
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Linq.Expressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
|
||||
public class EventListItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public EventType EventType { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public EventSeverity Severity { get; set; }
|
||||
public Guid? TrackingId { get; set; }
|
||||
public Guid? StrikeId { get; set; }
|
||||
public Guid? JobRunId { get; set; }
|
||||
public Guid? ArrInstanceId { get; set; }
|
||||
public Guid? DownloadClientId { get; set; }
|
||||
public SearchCommandStatus? SearchStatus { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public Guid? CycleId { get; set; }
|
||||
public bool IsDryRun { get; set; }
|
||||
public string? ItemTitle { get; set; }
|
||||
public string? ItemHash { get; set; }
|
||||
public int? StrikeCount { get; set; }
|
||||
public List<string> FailedImportReasons { get; set; } = [];
|
||||
public DeleteReason? DeleteReason { get; set; }
|
||||
public bool? RemoveFromClient { get; set; }
|
||||
public CleanReason? CleanReason { get; set; }
|
||||
public string? CleanedCategory { get; set; }
|
||||
public double? SeedRatio { get; set; }
|
||||
public double? SeedingTimeHours { get; set; }
|
||||
public string? OldCategory { get; set; }
|
||||
public string? NewCategory { get; set; }
|
||||
public bool? IsCategoryTag { get; set; }
|
||||
public SeekerSearchType? SearchType { get; set; }
|
||||
public SeekerSearchReason? SearchReason { get; set; }
|
||||
public List<string> GrabbedItems { get; set; } = [];
|
||||
|
||||
public static readonly Expression<Func<AppEvent, EventListItem>> FromEvent = e => new EventListItem
|
||||
{
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
EventType = e.EventType,
|
||||
Message = e.Message,
|
||||
Severity = e.Severity,
|
||||
TrackingId = e.TrackingId,
|
||||
StrikeId = e.StrikeId,
|
||||
JobRunId = e.JobRunId,
|
||||
ArrInstanceId = e.ArrInstanceId,
|
||||
DownloadClientId = e.DownloadClientId,
|
||||
SearchStatus = e.SearchStatus,
|
||||
CompletedAt = e.CompletedAt,
|
||||
CycleId = e.CycleId,
|
||||
IsDryRun = e.IsDryRun,
|
||||
ItemTitle = e.ItemTitle,
|
||||
ItemHash = e.ItemHash,
|
||||
StrikeCount = e.StrikeCount,
|
||||
FailedImportReasons = e.FailedImportReasons,
|
||||
DeleteReason = e.DeleteReason,
|
||||
RemoveFromClient = e.RemoveFromClient,
|
||||
CleanReason = e.CleanReason,
|
||||
CleanedCategory = e.CleanedCategory,
|
||||
SeedRatio = e.SeedRatio,
|
||||
SeedingTimeHours = e.SeedingTimeHours,
|
||||
OldCategory = e.OldCategory,
|
||||
NewCategory = e.NewCategory,
|
||||
IsCategoryTag = e.IsCategoryTag,
|
||||
SearchType = e.SearchType,
|
||||
SearchReason = e.SearchReason,
|
||||
GrabbedItems = e.GrabbedItems,
|
||||
};
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
|
||||
public sealed record EventTypeTimelineBucket
|
||||
{
|
||||
public DateTimeOffset Date { get; init; }
|
||||
|
||||
public Dictionary<string, int> Counts { get; init; } = new();
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
|
||||
public sealed record EventTypeTimelineResponse
|
||||
{
|
||||
public List<string> Types { get; init; } = [];
|
||||
|
||||
public List<EventTypeTimelineBucket> Buckets { get; init; } = [];
|
||||
}
|
||||
+10
-23
@@ -3,7 +3,6 @@ using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Serilog.Events;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
|
||||
@@ -25,8 +24,14 @@ public sealed record UpdateGeneralConfigRequest
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public bool ConnectivityCheckEnabled { get; init; }
|
||||
|
||||
public List<string> ConnectivityCheckUrls { get; init; } = [];
|
||||
|
||||
public ushort StrikeInactivityWindowHours { get; init; } = 24;
|
||||
|
||||
public ushort HistoryRetentionDays { get; init; } = 365;
|
||||
|
||||
public UpdateLoggingConfigRequest Log { get; init; } = new();
|
||||
|
||||
public UpdateAuthConfigRequest Auth { get; init; } = new();
|
||||
@@ -41,39 +46,21 @@ public sealed record UpdateGeneralConfigRequest
|
||||
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
existingConfig.ConnectivityCheckEnabled = ConnectivityCheckEnabled;
|
||||
existingConfig.ConnectivityCheckUrls = ConnectivityCheckUrls;
|
||||
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
|
||||
existingConfig.HistoryRetentionDays = HistoryRetentionDays;
|
||||
|
||||
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
|
||||
Auth.ApplyTo(existingConfig.Auth);
|
||||
|
||||
Validate(existingConfig);
|
||||
existingConfig.Validate();
|
||||
|
||||
ApplySideEffects(existingConfig, services, logger, loggingChanged);
|
||||
|
||||
return existingConfig;
|
||||
}
|
||||
|
||||
private static void Validate(GeneralConfig config)
|
||||
{
|
||||
if (config.HttpTimeout is 0)
|
||||
{
|
||||
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
|
||||
}
|
||||
|
||||
if (config.StrikeInactivityWindowHours is 0)
|
||||
{
|
||||
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be greater than 0");
|
||||
}
|
||||
|
||||
if (config.StrikeInactivityWindowHours > 168)
|
||||
{
|
||||
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
|
||||
}
|
||||
|
||||
config.Log.Validate();
|
||||
config.Auth.Validate();
|
||||
}
|
||||
|
||||
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
|
||||
{
|
||||
var dynamicHttpClientFactory = services.GetRequiredService<IDynamicHttpClientFactory>();
|
||||
|
||||
@@ -80,7 +80,7 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
var deletedHistory = await _dataContext.SeekerHistory
|
||||
var deletedHistory = await eventsContext.SeekerHistory
|
||||
.Where(h => h.IsDryRun)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
|
||||
-4
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
@@ -9,8 +8,6 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public JobTriggerMode TriggerMode { get; init; } = JobTriggerMode.Schedule;
|
||||
|
||||
public string CronExpression { get; init; } = "0/5 * * * * ?";
|
||||
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
@@ -38,7 +35,6 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
|
||||
{
|
||||
config.Enabled = Enabled;
|
||||
config.TriggerMode = TriggerMode;
|
||||
config.CronExpression = CronExpression;
|
||||
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||
config.IgnorePrivate = IgnorePrivate;
|
||||
|
||||
+1
-5
@@ -82,11 +82,7 @@ public sealed class MalwareBlockerConfigController : ControllerBase
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
// Webhook-only mode keeps the feature enabled but removes the cron trigger.
|
||||
bool scheduleEnabled = config.Enabled &&
|
||||
config is not ContentBlockerConfig { TriggerMode: JobTriggerMode.Webhook };
|
||||
|
||||
if (scheduleEnabled)
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed record SlowRuleDto : QueueRuleDto
|
||||
|
||||
[Range(0, double.MaxValue, ErrorMessage = "Maximum time cannot be negative")]
|
||||
public double MaxTimeHours { get; set; } = 0;
|
||||
|
||||
|
||||
public string? IgnoreAboveSize { get; set; }
|
||||
|
||||
public bool IgnoreWhileAltSpeedActive { get; set; } = true;
|
||||
}
|
||||
@@ -244,6 +244,7 @@ public class QueueRulesController : ControllerBase
|
||||
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
IgnoreWhileAltSpeedActive = ruleDto.IgnoreWhileAltSpeedActive,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
ChangeCategory = ruleDto.ChangeCategory,
|
||||
};
|
||||
@@ -305,6 +306,7 @@ public class QueueRulesController : ControllerBase
|
||||
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
IgnoreWhileAltSpeedActive = ruleDto.IgnoreWhileAltSpeedActive,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
ChangeCategory = ruleDto.ChangeCategory,
|
||||
};
|
||||
|
||||
+53
-46
@@ -2,9 +2,9 @@ using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
@@ -15,10 +15,14 @@ namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
public sealed class CustomFormatScoreController : ControllerBase
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IDatabaseProvider _databaseProvider;
|
||||
|
||||
public CustomFormatScoreController(DataContext dataContext)
|
||||
public CustomFormatScoreController(DataContext dataContext, EventsContext eventsContext, IDatabaseProvider databaseProvider)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_eventsContext = eventsContext;
|
||||
_databaseProvider = databaseProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -52,7 +56,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _dataContext.CustomFormatScoreEntries
|
||||
var query = _eventsContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
@@ -64,7 +68,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e => EF.Functions.Like(e.Title, pattern));
|
||||
query = query.Where(e => EF.Functions.Like(e.Title.ToLower(), pattern, "\\"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(qualityProfile))
|
||||
@@ -207,8 +211,10 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
? null
|
||||
: EventsContext.GetLikePattern(search);
|
||||
|
||||
const string upgradesCte =
|
||||
"""
|
||||
string historyTable = _databaseProvider.QualifyTable("custom_format_score_history", DbContextKind.Events);
|
||||
|
||||
string upgradesCte =
|
||||
$"""
|
||||
WITH scored AS (
|
||||
SELECT
|
||||
arr_instance_id,
|
||||
@@ -223,7 +229,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
PARTITION BY arr_instance_id, external_item_id, episode_id
|
||||
ORDER BY recorded_at
|
||||
) AS prev_score
|
||||
FROM custom_format_score_history
|
||||
FROM {historyTable}
|
||||
),
|
||||
upgrades AS (
|
||||
SELECT
|
||||
@@ -241,42 +247,42 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
)
|
||||
""";
|
||||
|
||||
const string filterClause =
|
||||
const string filterClause =
|
||||
"""
|
||||
WHERE (@instanceId IS NULL OR arr_instance_id = @instanceId)
|
||||
AND (@search IS NULL OR title LIKE @search ESCAPE '\')
|
||||
AND (@cutoff IS NULL OR upgraded_at >= @cutoff)
|
||||
WHERE arr_instance_id = COALESCE({0}, arr_instance_id)
|
||||
AND LOWER(title) LIKE COALESCE({1}, '%') ESCAPE '\'
|
||||
AND upgraded_at >= COALESCE({2}, upgraded_at)
|
||||
""";
|
||||
|
||||
SqliteParameter[] BuildCommonParameters() => new[]
|
||||
object[] BuildCommonParameters() => new[]
|
||||
{
|
||||
new SqliteParameter("@instanceId", instanceId.HasValue ? instanceId.Value : DBNull.Value),
|
||||
new SqliteParameter("@search", (object?)searchPattern ?? DBNull.Value),
|
||||
new SqliteParameter("@cutoff", (object?)cutoff ?? DBNull.Value),
|
||||
instanceId.HasValue ? instanceId.Value : (object)DBNull.Value,
|
||||
(object?)searchPattern ?? DBNull.Value,
|
||||
(object?)cutoff ?? DBNull.Value,
|
||||
};
|
||||
|
||||
string listSql =
|
||||
$"""
|
||||
{upgradesCte}
|
||||
SELECT * FROM upgrades
|
||||
{filterClause}
|
||||
ORDER BY {orderByClause}, upgraded_at DESC
|
||||
LIMIT @take OFFSET @skip
|
||||
""";
|
||||
$$"""
|
||||
{{upgradesCte}}
|
||||
SELECT * FROM upgrades
|
||||
{{filterClause}}
|
||||
ORDER BY {{orderByClause}}, upgraded_at DESC
|
||||
LIMIT {3} OFFSET {4}
|
||||
""";
|
||||
|
||||
SqliteParameter[] listParams =
|
||||
object[] listParams =
|
||||
[
|
||||
..BuildCommonParameters(),
|
||||
new("@take", pageSize),
|
||||
new("@skip", (page - 1) * pageSize),
|
||||
pageSize,
|
||||
(page - 1) * pageSize,
|
||||
];
|
||||
|
||||
var rows = await _dataContext.Database
|
||||
var rows = await _eventsContext.Database
|
||||
.SqlQueryRaw<UpgradeSqlRow>(listSql, listParams)
|
||||
.ToListAsync();
|
||||
|
||||
string countSql = $"{upgradesCte} SELECT COUNT(*) AS value FROM upgrades {filterClause}";
|
||||
int totalCount = await _dataContext.Database
|
||||
string countSql = $"{upgradesCte} SELECT COUNT(*) AS \"Value\" FROM upgrades {filterClause}";
|
||||
int totalCount = await _eventsContext.Database
|
||||
.SqlQueryRaw<int>(countSql, BuildCommonParameters())
|
||||
.FirstAsync();
|
||||
|
||||
@@ -347,28 +353,29 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
[HttpGet("instances")]
|
||||
public async Task<IActionResult> GetInstances()
|
||||
{
|
||||
var raw = await _dataContext.CustomFormatScoreEntries
|
||||
var raw = await _eventsContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.Join(
|
||||
_dataContext.ArrInstances.AsNoTracking(),
|
||||
e => e.ArrInstanceId,
|
||||
a => a.Id,
|
||||
(e, a) => new
|
||||
{
|
||||
Id = e.ArrInstanceId,
|
||||
a.Name,
|
||||
e.ItemType,
|
||||
e.QualityProfileName,
|
||||
})
|
||||
.Select(e => new
|
||||
{
|
||||
e.ArrInstanceId,
|
||||
e.ItemType,
|
||||
e.QualityProfileName,
|
||||
})
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
List<Guid> instanceIds = raw.Select(x => x.ArrInstanceId).Distinct().ToList();
|
||||
Dictionary<Guid, string> namesById = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Where(a => instanceIds.Contains(a.Id))
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Name);
|
||||
|
||||
var instances = raw
|
||||
.GroupBy(x => new { x.Id, x.Name, x.ItemType })
|
||||
.GroupBy(x => new { x.ArrInstanceId, x.ItemType })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.Id,
|
||||
g.Key.Name,
|
||||
Id = g.Key.ArrInstanceId,
|
||||
Name = namesById.TryGetValue(g.Key.ArrInstanceId, out string? name) ? name : "Unknown",
|
||||
ItemType = g.Key.ItemType,
|
||||
QualityProfiles = g
|
||||
.Select(x => x.QualityProfileName)
|
||||
@@ -389,7 +396,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> GetStats()
|
||||
{
|
||||
var entries = await _dataContext.CustomFormatScoreEntries
|
||||
var entries = await _eventsContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -401,7 +408,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
|
||||
// Count upgrades in the last 7 days
|
||||
var sevenDaysAgo = DateTimeOffset.UtcNow.AddDays(-7);
|
||||
var recentHistory = await _dataContext.CustomFormatScoreHistory
|
||||
var recentHistory = await _eventsContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.RecordedAt >= sevenDaysAgo)
|
||||
.OrderBy(h => h.RecordedAt)
|
||||
@@ -485,7 +492,7 @@ public sealed class CustomFormatScoreController : ControllerBase
|
||||
long itemId,
|
||||
[FromQuery] long episodeId = 0)
|
||||
{
|
||||
var history = await _dataContext.CustomFormatScoreHistory
|
||||
var history = await _eventsContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == instanceId
|
||||
&& h.ExternalItemId == itemId
|
||||
|
||||
@@ -43,13 +43,13 @@ public sealed class SearchStatsController : ControllerBase
|
||||
int searchesLast30Days = await searchEvents.CountAsync(e => e.Timestamp >= thirtyDaysAgo);
|
||||
|
||||
// History stats from DataContext
|
||||
int uniqueItemsSearched = await _dataContext.SeekerHistory
|
||||
int uniqueItemsSearched = await _eventsContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Select(h => h.ExternalItemId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
|
||||
int pendingReplacementSearches = await _dataContext.SearchQueue.CountAsync();
|
||||
int pendingReplacementSearches = await _eventsContext.SearchQueue.CountAsync();
|
||||
|
||||
// Per-instance stats
|
||||
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
@@ -59,7 +59,7 @@ public sealed class SearchStatsController : ControllerBase
|
||||
.Where(s => s.Enabled && s.ArrInstance.Enabled)
|
||||
.ToListAsync();
|
||||
|
||||
var historyByInstance = await _dataContext.SeekerHistory
|
||||
var historyByInstance = await _eventsContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.GroupBy(h => h.ArrInstanceId)
|
||||
.Select(g => new
|
||||
@@ -73,7 +73,7 @@ public sealed class SearchStatsController : ControllerBase
|
||||
|
||||
// Count items searched in current cycle per instance
|
||||
List<Guid> currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
|
||||
var cycleItemsByInstance = await _dataContext.SeekerHistory
|
||||
var cycleItemsByInstance = await _eventsContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => currentCycleIds.Contains(h.CycleId))
|
||||
.GroupBy(h => h.ArrInstanceId)
|
||||
@@ -164,7 +164,6 @@ public sealed class SearchStatsController : ControllerBase
|
||||
|
||||
var query = _eventsContext.Events
|
||||
.AsNoTracking()
|
||||
.Include(e => e.SearchEventData)
|
||||
.Where(e => e.EventType == EventType.SearchTriggered);
|
||||
|
||||
// Filter by instance ID
|
||||
@@ -179,12 +178,12 @@ public sealed class SearchStatsController : ControllerBase
|
||||
query = query.Where(e => e.CycleId == cycleId.Value);
|
||||
}
|
||||
|
||||
// Search by item title in SearchEventData
|
||||
// Search by item title
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e => e.SearchEventData != null
|
||||
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
|
||||
query = query.Where(e => e.ItemTitle != null
|
||||
&& EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\"));
|
||||
}
|
||||
|
||||
// Filter by search status (multi-valued)
|
||||
@@ -197,13 +196,13 @@ public sealed class SearchStatsController : ControllerBase
|
||||
if (searchType.HasValue)
|
||||
{
|
||||
SeekerSearchType typeValue = searchType.Value;
|
||||
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchType == typeValue);
|
||||
query = query.Where(e => e.SearchType == typeValue);
|
||||
}
|
||||
|
||||
if (searchReason.HasValue)
|
||||
{
|
||||
SeekerSearchReason reasonValue = searchReason.Value;
|
||||
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchReason == reasonValue);
|
||||
query = query.Where(e => e.SearchReason == reasonValue);
|
||||
}
|
||||
|
||||
// Filter by grabbed-result presence
|
||||
@@ -211,11 +210,11 @@ public sealed class SearchStatsController : ControllerBase
|
||||
{
|
||||
if (grabbed.Value)
|
||||
{
|
||||
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.GrabbedItems.Count > 0);
|
||||
query = query.Where(e => e.GrabbedItems.Count > 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(e => e.SearchEventData == null || e.SearchEventData.GrabbedItems.Count == 0);
|
||||
query = query.Where(e => e.GrabbedItems.Count == 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,14 +225,14 @@ public sealed class SearchStatsController : ControllerBase
|
||||
IOrderedQueryable<AppEvent> ordered = sortBy switch
|
||||
{
|
||||
SearchEventsSortBy.Title => ascending
|
||||
? query.OrderBy(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty)
|
||||
: query.OrderByDescending(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty),
|
||||
? query.OrderBy(e => e.ItemTitle ?? string.Empty)
|
||||
: query.OrderByDescending(e => e.ItemTitle ?? string.Empty),
|
||||
SearchEventsSortBy.Status => ascending
|
||||
? query.OrderBy(e => e.SearchStatus)
|
||||
: query.OrderByDescending(e => e.SearchStatus),
|
||||
SearchEventsSortBy.Type => ascending
|
||||
? query.OrderBy(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0)
|
||||
: query.OrderByDescending(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0),
|
||||
? query.OrderBy(e => e.SearchType)
|
||||
: query.OrderByDescending(e => e.SearchType),
|
||||
_ => ascending
|
||||
? query.OrderBy(e => e.Timestamp)
|
||||
: query.OrderByDescending(e => e.Timestamp),
|
||||
@@ -273,12 +272,12 @@ public sealed class SearchStatsController : ControllerBase
|
||||
InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it)
|
||||
? it.ToString()
|
||||
: null,
|
||||
ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown",
|
||||
SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive,
|
||||
SearchReason = e.SearchEventData?.SearchReason,
|
||||
ItemTitle = e.ItemTitle ?? "Unknown",
|
||||
SearchType = e.SearchType ?? SeekerSearchType.Proactive,
|
||||
SearchReason = e.SearchReason,
|
||||
SearchStatus = e.SearchStatus,
|
||||
CompletedAt = e.CompletedAt,
|
||||
GrabbedItems = e.SearchEventData?.GrabbedItems ?? [],
|
||||
GrabbedItems = e.GrabbedItems,
|
||||
CycleId = e.CycleId,
|
||||
IsDryRun = e.IsDryRun,
|
||||
}).ToList();
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
namespace Cleanuparr.Api.Features.Strikes.Contracts.Responses;
|
||||
|
||||
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 DateTimeOffset LatestStrikeAt { get; set; }
|
||||
public DateTimeOffset 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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Api.Features.Strikes.Contracts.Responses;
|
||||
|
||||
public class RecentStrikeDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Api.Features.Strikes.Contracts.Responses;
|
||||
|
||||
public class StrikeDetailDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public long? LastDownloadedBytes { get; set; }
|
||||
public Guid JobRunId { get; set; }
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// The *arr webhook event types Cleanuparr acts on. Unrecognized events parse to
|
||||
/// <see cref="Unknown"/> and are ignored.
|
||||
/// </summary>
|
||||
public enum ArrWebhookEventType
|
||||
{
|
||||
Unknown = 0,
|
||||
Test,
|
||||
Grab,
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used
|
||||
/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored.
|
||||
/// </summary>
|
||||
public sealed record ArrWebhookPayload
|
||||
{
|
||||
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
|
||||
public string? EventType { get; init; }
|
||||
|
||||
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
|
||||
public string? DownloadId { get; init; }
|
||||
|
||||
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
|
||||
public ArrWebhookContent? Series { get; init; }
|
||||
|
||||
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
|
||||
public ArrWebhookContent? Movie { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ArrWebhookContent
|
||||
{
|
||||
public long Id { get; init; }
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Webhooks.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
|
||||
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
|
||||
/// be pasted directly into the *arr Webhook connection.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public sealed class WebhooksController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<WebhooksController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public WebhooksController(
|
||||
ILogger<WebhooksController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpPost("malware-blocker/{instanceId:guid}")]
|
||||
public async Task<IActionResult> TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
|
||||
{
|
||||
Enum.TryParse(payload.EventType, ignoreCase: true, out ArrWebhookEventType eventType);
|
||||
|
||||
// The Test button sends an event we acknowledge without doing any work.
|
||||
if (eventType is ArrWebhookEventType.Test)
|
||||
{
|
||||
_logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
if (eventType is not ArrWebhookEventType.Grab)
|
||||
{
|
||||
_logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}",
|
||||
payload.EventType, instanceId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
ArrConfig? arrConfig;
|
||||
ArrInstance? instance;
|
||||
ContentBlockerConfig config;
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
arrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
|
||||
instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId);
|
||||
config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
|
||||
if (arrConfig is null || instance is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"No arr instance found with id {instanceId}");
|
||||
}
|
||||
|
||||
if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status422UnprocessableEntity, "MalwareBlocker webhooks are only supported for Sonarr and Radarr");
|
||||
}
|
||||
|
||||
if (!config.Enabled || config.TriggerMode is JobTriggerMode.Schedule)
|
||||
{
|
||||
_logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
long contentId = arrConfig.Type switch
|
||||
{
|
||||
InstanceType.Sonarr => payload.Series?.Id ?? 0,
|
||||
InstanceType.Radarr => payload.Movie?.Id ?? 0,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api;
|
||||
@@ -25,12 +26,30 @@ public static class HostExtensions
|
||||
? "Cleanuparr version not detected"
|
||||
: $"Cleanuparr {formattedVersion}"
|
||||
);
|
||||
|
||||
|
||||
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
|
||||
|
||||
|
||||
LogGcConfiguration(logger);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void LogGcConfiguration(ILogger logger)
|
||||
{
|
||||
// Surface the active GC settings
|
||||
GCMemoryInfo info = GC.GetGCMemoryInfo();
|
||||
long totalAvailableMb = info.TotalAvailableMemoryBytes / (1024 * 1024);
|
||||
long heapHardLimitMb = info.HighMemoryLoadThresholdBytes / (1024 * 1024);
|
||||
|
||||
logger.LogInformation(
|
||||
"Garbage Collector config | server={ServerGC} concurrent={ConcurrentGC} latencyMode={Latency} totalAvailable={TotalMb} MB highMemoryLoadThreshold={HighMb} MB",
|
||||
System.Runtime.GCSettings.IsServerGC,
|
||||
info.Concurrent,
|
||||
System.Runtime.GCSettings.LatencyMode,
|
||||
totalAvailableMb,
|
||||
heapHardLimitMb);
|
||||
}
|
||||
|
||||
private static string? FormatVersion(Version? version)
|
||||
{
|
||||
if (version is null)
|
||||
@@ -48,27 +67,30 @@ public static class HostExtensions
|
||||
|
||||
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
|
||||
{
|
||||
IDatabaseProvider provider = DatabaseProviderFactory.Current;
|
||||
|
||||
// Apply data db migrations first — events migrations may ATTACH cleanuparr.db
|
||||
// and reference its schema, so it must be up to date before events migrate.
|
||||
await using var configContext = DataContext.CreateStaticInstance();
|
||||
await using DataContext configContext = DataContext.CreateStaticInstance();
|
||||
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
await provider.OnPostMigrateAsync(configContext.Database, DbContextKind.Data, CancellationToken.None);
|
||||
|
||||
// Apply events db migrations
|
||||
await using var eventsContext = EventsContext.CreateStaticInstance();
|
||||
await using EventsContext eventsContext = EventsContext.CreateStaticInstance();
|
||||
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await eventsContext.Database.MigrateAsync();
|
||||
}
|
||||
await provider.OnPostMigrateAsync(eventsContext.Database, DbContextKind.Events, CancellationToken.None);
|
||||
|
||||
// Apply users db migrations
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
await using UsersContext usersContext = UsersContext.CreateStaticInstance();
|
||||
if ((await usersContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await usersContext.Database.MigrateAsync();
|
||||
}
|
||||
await provider.OnPostMigrateAsync(usersContext.Database, DbContextKind.Users, CancellationToken.None);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
@@ -113,7 +112,6 @@ public class BackgroundJobManager : IHostedService
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerWebhookJob(cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
await RegisterSeekerJob(seekerConfig, cancellationToken);
|
||||
@@ -146,24 +144,13 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
|
||||
|
||||
// Only add the cron trigger when scheduling is part of the trigger mode
|
||||
if (config.Enabled && config.TriggerMode is not JobTriggerMode.Webhook)
|
||||
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger).
|
||||
/// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of
|
||||
/// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook
|
||||
/// is received.
|
||||
/// </summary>
|
||||
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
|
||||
@@ -286,17 +273,17 @@ public class BackgroundJobManager : IHostedService
|
||||
/// <summary>
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default, string? jobKeyName = null)
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
string typeName = jobKeyName ?? typeof(T).Name;
|
||||
|
||||
string typeName = typeof(T).Name;
|
||||
var jobKey = new JobKey(typeName);
|
||||
|
||||
|
||||
// Check if job already exists
|
||||
if (await _scheduler.CheckExists(jobKey, cancellationToken))
|
||||
{
|
||||
|
||||
@@ -48,8 +48,6 @@ public sealed class GenericJob<T> : IJob
|
||||
ContextProvider.SetJobRunId(jobRunId);
|
||||
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
|
||||
|
||||
SetWebhookScanTarget(context);
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
@@ -77,34 +75,6 @@ public sealed class GenericJob<T> : IJob
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the
|
||||
/// handler via the ContextProvider so the run scans only that download. No-op for normal triggers.
|
||||
/// </summary>
|
||||
private static void SetWebhookScanTarget(IJobExecutionContext context)
|
||||
{
|
||||
JobDataMap dataMap = context.MergedJobDataMap;
|
||||
|
||||
if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) ||
|
||||
!Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty;
|
||||
long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey);
|
||||
int retryIndex = dataMap.ContainsKey(WebhookScanTarget.RetryIndexKey)
|
||||
? dataMap.GetInt(WebhookScanTarget.RetryIndexKey)
|
||||
: 0;
|
||||
|
||||
ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType, retryIndex));
|
||||
}
|
||||
|
||||
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Infrastructure.Json;
|
||||
|
||||
namespace Cleanuparr.Api.Json;
|
||||
|
||||
public static class CleanuparrJsonConfiguration
|
||||
{
|
||||
public static void ConfigureCore(JsonSerializerOptions options)
|
||||
{
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
}
|
||||
|
||||
public static void ConfigureApi(JsonSerializerOptions options)
|
||||
{
|
||||
ConfigureCore(options);
|
||||
options.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
}
|
||||
|
||||
public static void ConfigureApiInbound(JsonSerializerOptions options)
|
||||
{
|
||||
ConfigureApi(options);
|
||||
options.TypeInfoResolver = options.TypeInfoResolver!
|
||||
.WithAddedModifier(CleanuparrJsonOptions.IgnoreNullForNonNullable);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.Commands;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Serilog;
|
||||
|
||||
if (MigrateToPostgresCommand.Matches(args))
|
||||
{
|
||||
Environment.Exit(await MigrateToPostgresCommand.RunAsync(args));
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile(ConfigurationPathProvider.GetConfigFilePath(), optional: true, reloadOnChange: true);
|
||||
DatabaseConfigProvider.Initialize(builder.Configuration);
|
||||
|
||||
await builder.InitAsync();
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
@@ -29,13 +39,10 @@ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
}
|
||||
}
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile(Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.json"), optional: true, reloadOnChange: true);
|
||||
|
||||
int.TryParse(builder.Configuration.GetValue<string>("PORT"), out int port);
|
||||
int.TryParse(builder.Configuration.GetValue<string>(ConfigurationKeys.Port), out int port);
|
||||
port = port is 0 ? 11011 : port;
|
||||
|
||||
string? bindAddress = builder.Configuration.GetValue<string>("BIND_ADDRESS");
|
||||
string? bindAddress = builder.Configuration.GetValue<string>(ConfigurationKeys.BindAddress);
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -61,13 +68,6 @@ builder.Services.AddResponseCompression(options =>
|
||||
options.EnableForHttps = true;
|
||||
});
|
||||
|
||||
// Configure JSON options to serialize enums as strings
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
// Add services to the container
|
||||
builder.Services
|
||||
.AddInfrastructure(builder.Configuration)
|
||||
@@ -111,7 +111,7 @@ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure BASE_PATH immediately after app build and before any other configuration
|
||||
string? basePath = app.Configuration.GetValue<string>("BASE_PATH");
|
||||
string? basePath = app.Configuration.GetValue<string>(ConfigurationKeys.BasePath);
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
if (basePath is not null)
|
||||
|
||||
@@ -6,8 +6,4 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,8 +1,17 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// An image of a series or a movie in Sonarr, Radarr or Whisparr.
|
||||
/// </summary>
|
||||
public record Image
|
||||
{
|
||||
public required string CoverType { get; init; }
|
||||
|
||||
public required Uri RemoteUrl { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// The type of the image, for example "poster" or "screenshot".
|
||||
/// </summary>
|
||||
public string CoverType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the image on the server of the metadata provider.
|
||||
/// </summary>
|
||||
public Uri? RemoteUrl { get; init; }
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// An image of an album in Lidarr.
|
||||
/// </summary>
|
||||
public record LidarrImage
|
||||
{
|
||||
public required string CoverType { get; init; }
|
||||
|
||||
public required Uri Url { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// The type of the image, for example "cover".
|
||||
/// </summary>
|
||||
public string CoverType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the image.
|
||||
/// </summary>
|
||||
public Uri? Url { get; init; }
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// One page of the queue of an *arr application.
|
||||
/// </summary>
|
||||
public record QueueListResponse
|
||||
{
|
||||
public required int TotalRecords { get; init; }
|
||||
public required IReadOnlyList<QueueRecord> Records { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// The number of items in the queue, on this page and on the other pages.
|
||||
/// </summary>
|
||||
public int TotalRecords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The items on this page.
|
||||
/// </summary>
|
||||
public IReadOnlyList<QueueRecord> Records { get; init; } = [];
|
||||
}
|
||||
@@ -1,41 +1,126 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// One item in the queue of an *arr application.
|
||||
/// The *arr applications share this type, and each one sets only its own fields.
|
||||
/// </summary>
|
||||
public sealed record QueueRecord
|
||||
{
|
||||
// Sonarr and Whisparr v2
|
||||
/// <summary>
|
||||
/// The ID of the series.
|
||||
/// </summary>
|
||||
public long SeriesId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the episode.
|
||||
/// </summary>
|
||||
public long EpisodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of the season.
|
||||
/// </summary>
|
||||
public long SeasonNumber { get; init; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The data about the series.
|
||||
/// </summary>
|
||||
public QueueSeries? Series { get; init; }
|
||||
|
||||
|
||||
// Radarr and Whisparr v3
|
||||
/// <summary>
|
||||
/// The ID of the movie.
|
||||
/// </summary>
|
||||
public long MovieId { get; init; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The data about the movie.
|
||||
/// </summary>
|
||||
public QueueMovie? Movie { get; init; }
|
||||
|
||||
|
||||
// Lidarr
|
||||
/// <summary>
|
||||
/// The ID of the artist.
|
||||
/// </summary>
|
||||
public long ArtistId { get; init; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the album.
|
||||
/// </summary>
|
||||
public long AlbumId { get; init; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The data about the album.
|
||||
/// </summary>
|
||||
public QueueAlbum? Album { get; init; }
|
||||
|
||||
|
||||
// Readarr
|
||||
/// <summary>
|
||||
/// The ID of the author.
|
||||
/// </summary>
|
||||
public long AuthorId { get; init; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the book.
|
||||
/// </summary>
|
||||
public long BookId { get; init; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The data about the book.
|
||||
/// </summary>
|
||||
public QueueBook? Book { get; init; }
|
||||
|
||||
|
||||
// common
|
||||
public required string Title { get; init; }
|
||||
public string Status { get; init; }
|
||||
public string TrackedDownloadStatus { get; init; }
|
||||
public string TrackedDownloadState { get; init; }
|
||||
/// <summary>
|
||||
/// The name of the release.
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The status of the queue item, for example "downloading" or "delay".
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The status of the tracked download, for example "warning".
|
||||
/// A pending release does not have a tracked download, and the value is then empty.
|
||||
/// </summary>
|
||||
public string TrackedDownloadStatus { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The state of the tracked download, for example "importBlocked".
|
||||
/// A pending release does not have a tracked download, and the value is then empty.
|
||||
/// </summary>
|
||||
public string TrackedDownloadState { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The messages that tell more about the status of the queue item.
|
||||
/// </summary>
|
||||
public List<TrackedDownloadStatusMessage>? StatusMessages { get; init; }
|
||||
public required string DownloadId { get; init; }
|
||||
public required string Protocol { get; init; }
|
||||
public required long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The hash that the download client uses for the item.
|
||||
/// A pending release is not in a download client, and the value is then empty.
|
||||
/// </summary>
|
||||
public string DownloadId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the download client that has the item.
|
||||
/// </summary>
|
||||
public string? DownloadClient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The protocol of the release, for example "torrent" or "usenet".
|
||||
/// </summary>
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the queue item.
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of bytes that the download client must still get.
|
||||
/// </summary>
|
||||
public long SizeLeft { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
public static class QueueRecordExtensions
|
||||
{
|
||||
public static bool IsIgnored(this QueueRecord record, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
bool hasDownloadClient = !string.IsNullOrWhiteSpace(record.DownloadClient);
|
||||
|
||||
foreach (string ignored in ignoredDownloads)
|
||||
{
|
||||
if (record.DownloadId.Equals(ignored, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasDownloadClient && record.DownloadClient!.Equals(ignored, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
namespace Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// An image of a book in Readarr.
|
||||
/// </summary>
|
||||
public sealed record ReadarrImage
|
||||
{
|
||||
public required string CoverType { get; init; }
|
||||
|
||||
public required Uri Url { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// The type of the image, for example "cover".
|
||||
/// </summary>
|
||||
public string CoverType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the image.
|
||||
/// </summary>
|
||||
public Uri? Url { get; init; }
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
/// <summary>
|
||||
/// A tag of an *arr application.
|
||||
/// </summary>
|
||||
public sealed record Tag
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// The ID of the tag.
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the tag.
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Request;
|
||||
|
||||
public class DelugeRequest
|
||||
{
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
[JsonPropertyName("id")]
|
||||
public int RequestId { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "method")]
|
||||
[JsonPropertyName("method")]
|
||||
public string Method { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "params")]
|
||||
[JsonPropertyName("params")]
|
||||
public List<object> Params { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public NullValueHandling NullValueHandling { get; set; }
|
||||
|
||||
public DelugeRequest(int requestId, string method, params object[]? parameters)
|
||||
{
|
||||
RequestId = requestId;
|
||||
@@ -26,7 +23,5 @@ public class DelugeRequest
|
||||
{
|
||||
Params.AddRange(parameters);
|
||||
}
|
||||
|
||||
NullValueHandling = NullValueHandling.Include;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,21 @@
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
/// <summary>
|
||||
/// The root of the file tree of a torrent in Deluge.
|
||||
/// </summary>
|
||||
public sealed record DelugeContents
|
||||
{
|
||||
/// <summary>
|
||||
/// The child nodes of the root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contents")]
|
||||
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of the root.
|
||||
/// The value is always "dir".
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } // Always "dir" for the root
|
||||
public string Type { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
/// <summary>
|
||||
/// The error of a response from Deluge.
|
||||
/// </summary>
|
||||
public sealed record DelugeError
|
||||
{
|
||||
[JsonProperty(PropertyName = "message")]
|
||||
public String Message { get; set; }
|
||||
/// <summary>
|
||||
/// The text of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "code")]
|
||||
/// <summary>
|
||||
/// The code of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public int Code { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,68 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
/// <summary>
|
||||
/// One node in the file tree of a torrent in Deluge.
|
||||
/// A node is a file or a directory, and the value of <see cref="Type"/> tells which one it is.
|
||||
/// Deluge sends the file fields only on a file node.
|
||||
/// </summary>
|
||||
public class DelugeFileOrDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the node.
|
||||
/// The value is "file" or "dir".
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } // "file" or "dir"
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The child nodes of a directory node.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contents")]
|
||||
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; } // Recursive property for directories
|
||||
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The position of the file in the list of files of the torrent.
|
||||
/// Deluge sends this field only on a file node, and the value is 0 on a directory node.
|
||||
/// </summary>
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; set; }
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The path of the node in the torrent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; }
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The size of the node in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public int? Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The offset of the file in the data of the torrent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The part of the node that Deluge has, as a value from 0 to 100.
|
||||
/// </summary>
|
||||
[JsonPropertyName("progress")]
|
||||
public double? Progress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The download priority of the node.
|
||||
/// A value of 0 tells Deluge to skip the file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public required int Priority { get; set; }
|
||||
public int Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The progress of each file below a directory node.
|
||||
/// </summary>
|
||||
[JsonPropertyName("progresses")]
|
||||
public List<double> Progresses { get; set; }
|
||||
}
|
||||
public List<double> Progresses { get; set; } = [];
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
public sealed record DelugeResponse<T>
|
||||
{
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
[JsonPropertyName("id")]
|
||||
public int ResponseId { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "result")]
|
||||
[JsonPropertyName("result")]
|
||||
public T? Result { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "error")]
|
||||
[JsonPropertyName("error")]
|
||||
public DelugeError? Error { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
public record DelugeTorrent
|
||||
{
|
||||
[JsonProperty(PropertyName = "comment")]
|
||||
[JsonPropertyName("comment")]
|
||||
public string Comment { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "is_seed")]
|
||||
[JsonPropertyName("is_seed")]
|
||||
public bool IsSeed { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "hash")]
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "paused")]
|
||||
[JsonPropertyName("paused")]
|
||||
public bool Paused { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "ratio")]
|
||||
[JsonPropertyName("ratio")]
|
||||
public double Ratio { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "message")]
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "name")]
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "label")]
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; }
|
||||
}
|
||||
@@ -1,110 +1,110 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
|
||||
public sealed record DelugeTorrentExtended : DelugeTorrent
|
||||
{
|
||||
[JsonProperty(PropertyName = "total_done")]
|
||||
[JsonPropertyName("total_done")]
|
||||
public long TotalDone { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_payload_download")]
|
||||
[JsonPropertyName("total_payload_download")]
|
||||
public long TotalPayloadDownload { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_uploaded")]
|
||||
[JsonPropertyName("total_uploaded")]
|
||||
public long TotalUploaded { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "next_announce")]
|
||||
[JsonPropertyName("next_announce")]
|
||||
public int NextAnnounce { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tracker_status")]
|
||||
[JsonPropertyName("tracker_status")]
|
||||
public string TrackerStatus { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "num_pieces")]
|
||||
[JsonPropertyName("num_pieces")]
|
||||
public int NumPieces { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "piece_length")]
|
||||
[JsonPropertyName("piece_length")]
|
||||
public long PieceLength { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "is_auto_managed")]
|
||||
[JsonPropertyName("is_auto_managed")]
|
||||
public bool IsAutoManaged { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "active_time")]
|
||||
[JsonPropertyName("active_time")]
|
||||
public long ActiveTime { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "seeding_time")]
|
||||
[JsonPropertyName("seeding_time")]
|
||||
public long SeedingTime { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "time_since_transfer")]
|
||||
[JsonPropertyName("time_since_transfer")]
|
||||
public long TimeSinceTransfer { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "seed_rank")]
|
||||
[JsonPropertyName("seed_rank")]
|
||||
public int SeedRank { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "last_seen_complete")]
|
||||
[JsonPropertyName("last_seen_complete")]
|
||||
public long LastSeenComplete { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "completed_time")]
|
||||
[JsonPropertyName("completed_time")]
|
||||
public long CompletedTime { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "owner")] public string Owner { get; set; }
|
||||
[JsonPropertyName("owner")] public string Owner { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "public")]
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "shared")]
|
||||
[JsonPropertyName("shared")]
|
||||
public bool Shared { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "queue")] public int Queue { get; set; }
|
||||
[JsonPropertyName("queue")] public int Queue { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_wanted")]
|
||||
[JsonPropertyName("total_wanted")]
|
||||
public long TotalWanted { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "state")] public string State { get; set; }
|
||||
[JsonPropertyName("state")] public string State { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "progress")]
|
||||
[JsonPropertyName("progress")]
|
||||
public float Progress { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "num_seeds")]
|
||||
[JsonPropertyName("num_seeds")]
|
||||
public int NumSeeds { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_seeds")]
|
||||
[JsonPropertyName("total_seeds")]
|
||||
public int TotalSeeds { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "num_peers")]
|
||||
[JsonPropertyName("num_peers")]
|
||||
public int NumPeers { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_peers")]
|
||||
[JsonPropertyName("total_peers")]
|
||||
public int TotalPeers { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "download_payload_rate")]
|
||||
[JsonPropertyName("download_payload_rate")]
|
||||
public long DownloadPayloadRate { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "upload_payload_rate")]
|
||||
[JsonPropertyName("upload_payload_rate")]
|
||||
public long UploadPayloadRate { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "eta")] public long Eta { get; set; }
|
||||
[JsonPropertyName("eta")] public long Eta { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "distributed_copies")]
|
||||
[JsonPropertyName("distributed_copies")]
|
||||
public float DistributedCopies { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "time_added")]
|
||||
[JsonPropertyName("time_added")]
|
||||
public int TimeAdded { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tracker_host")]
|
||||
[JsonPropertyName("tracker_host")]
|
||||
public string TrackerHost { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "download_location")]
|
||||
[JsonPropertyName("download_location")]
|
||||
public string DownloadLocation { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_remaining")]
|
||||
[JsonPropertyName("total_remaining")]
|
||||
public long TotalRemaining { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "max_download_speed")]
|
||||
[JsonPropertyName("max_download_speed")]
|
||||
public long MaxDownloadSpeed { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "max_upload_speed")]
|
||||
[JsonPropertyName("max_upload_speed")]
|
||||
public long MaxUploadSpeed { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "seeds_peers_ratio")]
|
||||
[JsonPropertyName("seeds_peers_ratio")]
|
||||
public float SeedsPeersRatio { get; set; }
|
||||
}
|
||||
Loaded 100 of 794 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user