mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 03:47:42 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 | ||
|
|
54cd037cd2 | ||
|
|
2ebf67d44d | ||
|
|
d2d294b93c | ||
|
|
39eb91ac48 | ||
|
|
53376d94d9 | ||
|
|
f51973bb7b | ||
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 | ||
|
|
5fea8a0041 | ||
|
|
2333c86a08 | ||
|
|
cbfc1b2875 | ||
|
|
62e10afe7b | ||
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 | ||
|
|
df986a2e36 | ||
|
|
8a2aca79f7 | ||
|
|
e1cc0dba28 | ||
|
|
660dce0fa7 | ||
|
|
ee343ba469 | ||
|
|
d038738008 | ||
|
|
70f9995041 | ||
|
|
6a1aaec7c2 | ||
|
|
50e486bf5f | ||
|
|
bc15a9a934 | ||
|
|
573dbcf882 | ||
|
|
94acd9afa4 | ||
|
|
65d25a72a9 | ||
|
|
97eb2fce44 | ||
|
|
701829001c | ||
|
|
8aeeca111c | ||
|
|
c43936ce81 | ||
|
|
f35eb0c922 | ||
|
|
b2b0626b44 | ||
|
|
40f108d7ca | ||
|
|
6570f74b7e | ||
|
|
16f216cf84 | ||
|
|
69551edeff | ||
|
|
7192796e89 | ||
|
|
1d1ee7972f | ||
|
|
8bd6b86018 | ||
|
|
6abb542271 | ||
|
|
2aceae3078 | ||
|
|
65b200a68e | ||
|
|
de0c881944 | ||
|
|
d0ef01d79b | ||
|
|
9457236e99 | ||
|
|
d43b4fc1c4 | ||
|
|
e9750429eb | ||
|
|
b71b268b08 | ||
|
|
a708d22b27 | ||
|
|
a9a3b08ad6 | ||
|
|
1d1e8679e4 | ||
|
|
142d445ed0 | ||
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 | ||
|
|
b16fa70774 | ||
|
|
b343165644 | ||
|
|
02dff0bb9b | ||
|
|
ac3be75082 | ||
|
|
a1663b865a | ||
|
|
c97a416d1e | ||
|
|
d28ab42303 | ||
|
|
fbb2bba3b6 | ||
|
|
08eda22587 | ||
|
|
a4045eebd3 | ||
|
|
a57cbccbb4 | ||
|
|
2221f118bb | ||
|
|
2cc3eb4ebb | ||
|
|
3a064a22bd | ||
|
|
ee764ff215 | ||
|
|
402677b69b | ||
|
|
97f63434fd | ||
|
|
07d0cf07e3 | ||
|
|
4be107439a | ||
|
|
89ef03a859 | ||
|
|
905384034d | ||
|
|
bf826da1ae | ||
|
|
6aac35181b | ||
|
|
efbf60dcdd | ||
|
|
ebb166a7b9 | ||
|
|
7aced28262 | ||
|
|
ae3e793498 | ||
|
|
4eb98b18a1 | ||
|
|
128e7e5f11 | ||
|
|
d224b2dea0 | ||
|
|
16e823b8d3 | ||
|
|
f2f11e3472 | ||
|
|
a3549c80a9 | ||
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 | ||
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 | ||
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac | ||
|
|
323bfc4d2e | ||
|
|
dca45585ca | ||
|
|
8b5918d221 | ||
|
|
9c227c1f59 | ||
|
|
2ad4499a6f | ||
|
|
33a5bf9ab3 | ||
|
|
de06d1c2d3 | ||
|
|
72855bc030 | ||
|
|
b185ea6899 | ||
|
|
1e0127e97e | ||
|
|
5bdbc98d68 | ||
|
|
e1aeb3da31 | ||
|
|
283b09e8f1 | ||
|
|
b03c96249b | ||
|
|
2971445090 | ||
|
|
55c23419cd | ||
|
|
c4b9d9503a | ||
|
|
823b73d9f0 | ||
|
|
31632d25a4 | ||
|
|
c59951a39c | ||
|
|
d9140d7b5b | ||
|
|
90865a73b5 | ||
|
|
cc45233223 | ||
|
|
5d12d601ae | ||
|
|
88f40438af | ||
|
|
0a9ec06841 | ||
|
|
a0ca6ec4b8 | ||
|
|
eb6cf96470 | ||
|
|
2ca0616771 | ||
|
|
bc85144e60 | ||
|
|
236e31c841 | ||
|
|
7a15139aa6 | ||
|
|
fb6ccfd011 | ||
|
|
ef85e2b690 | ||
|
|
bb734230aa | ||
|
|
aa31c31955 | ||
|
|
1a89822f36 | ||
|
|
fc9e0eca36 | ||
|
|
0010dcb1c6 | ||
|
|
0ab8611f29 | ||
|
|
9e02408a7e | ||
|
|
1bd0db05e6 | ||
|
|
fb438f2ca7 | ||
|
|
d4de7f2ec3 | ||
|
|
98ee1943f9 | ||
|
|
4a57c0fba3 | ||
|
|
db0698d515 | ||
|
|
712cc9ff1e | ||
|
|
501be0e4e7 | ||
|
|
19b7613eea | ||
|
|
3d9cd8f6a9 | ||
|
|
c8add22d3d | ||
|
|
69d8cc8fa0 | ||
|
|
8b8a4b3837 | ||
|
|
c45006f219 | ||
|
|
bc306a37c9 | ||
|
|
aab0487020 | ||
|
|
ca892ce188 | ||
|
|
cff5dc20e5 | ||
|
|
0050c45f09 | ||
|
|
2a8f4634e5 | ||
|
|
81da704e6f | ||
|
|
c82b5e11b1 | ||
|
|
c36d9eb9cf | ||
|
|
2f21603e8e | ||
|
|
586f9964b5 | ||
|
|
124670bb98 | ||
|
|
baf6a8c2f4 | ||
|
|
cd345afc54 | ||
|
|
246ec4d6eb | ||
|
|
569eeae181 | ||
|
|
5a0ef56074 | ||
|
|
09bd4321fb | ||
|
|
4939e37210 | ||
|
|
9463d7587f | ||
|
|
7d2bf41bec | ||
|
|
93bb8cc18d | ||
|
|
449d9e623f | ||
|
|
3a50d9be3c | ||
|
|
693f80fe6a | ||
|
|
8cfc73213a | ||
|
|
6fbae768a4 | ||
|
|
8e9d0127e0 | ||
|
|
b92d70769a | ||
|
|
75b001cf6a | ||
|
|
479ca7884e | ||
|
|
00d8910118 | ||
|
|
bd28c7ab05 | ||
|
|
720279df65 | ||
|
|
2d4ec648b8 | ||
|
|
704fdaca4a | ||
|
|
b134136e51 | ||
|
|
5ca717d7e0 | ||
|
|
7068ee5e5a | ||
|
|
9f770473e5 | ||
|
|
5fe0f5750a | ||
|
|
b8ce225ccc | ||
|
|
f21f7388b7 | ||
|
|
a1354f231a | ||
|
|
4bc1c33e81 | ||
|
|
32bcbab523 | ||
|
|
b94ae21e11 | ||
|
|
a92ebd75c2 | ||
|
|
e6d3929fc9 | ||
|
|
a68e13af35 | ||
|
|
324c3ace8f | ||
|
|
3a9d5d9085 | ||
|
|
89a6eaf0ce | ||
|
|
027c4a0f4d | ||
|
|
81990c6768 | ||
|
|
ba02aa0e49 | ||
|
|
5adbdbd920 | ||
|
|
b3b211d956 | ||
|
|
279bd6d82d | ||
|
|
5dced28228 | ||
|
|
51bdaf64e4 | ||
|
|
9c8e0ebedc | ||
|
|
e1bea8a8c8 | ||
|
|
a6d3820104 | ||
|
|
36c793a5fb | ||
|
|
aade8a91c3 | ||
|
|
3fe7c3de1a | ||
|
|
596a5aed8d | ||
|
|
19b3675701 | ||
|
|
1713d0fd1e | ||
|
|
3a95a302c0 | ||
|
|
e738ba2334 | ||
|
|
c813215f3e | ||
|
|
0f63a2d271 | ||
|
|
133c34de53 | ||
|
|
a3ca735b12 | ||
|
|
519ab6a0cd | ||
|
|
0c691a540a |
No files matched your search
@@ -0,0 +1 @@
|
||||
github: Flaminel
|
||||
@@ -6,7 +6,14 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve cleanuperr!
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
@@ -14,8 +21,12 @@ body:
|
||||
options:
|
||||
- label: Reviewed the documentation.
|
||||
required: true
|
||||
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||
required: true
|
||||
- label: Ensured I am using the latest version.
|
||||
required: true
|
||||
- label: Enabled verbose logging.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -23,14 +34,6 @@ body:
|
||||
description: If applicable, mention what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of our software are you running?
|
||||
placeholder: e.g. 1.3.0 or latest
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
|
||||
@@ -6,7 +6,26 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve cleanuperr!
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: Implementation & testing support
|
||||
description: The requester should help answer questions, provide support for the implementation and test changes.
|
||||
options:
|
||||
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
|
||||
required: true
|
||||
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
|
||||
required: true
|
||||
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
@@ -7,6 +7,26 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: "Before submitting a help request, I have:"
|
||||
options:
|
||||
- label: Reviewed the documentation.
|
||||
required: true
|
||||
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||
required: true
|
||||
- label: Ensured I am using the latest version.
|
||||
required: true
|
||||
- label: Enabled verbose logging.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/SCtMCgtsc4
|
||||
about: Join our Discord for real-time help and discussions
|
||||
- name: Documentation
|
||||
url: https://cleanuparr.github.io/Cleanuparr/
|
||||
about: Check the documentation for configurations and usage guidelines
|
||||
@@ -0,0 +1,24 @@
|
||||
## Description
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Related Issue
|
||||
Closes #ISSUE_NUMBER
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
<!-- Add screenshots here -->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
|
||||
- [ ] I have announced my intent to work on this and received approval
|
||||
- [ ] My code follows the project's code standards
|
||||
- [ ] I have tested my changes thoroughly
|
||||
- [ ] I have updated relevant documentation
|
||||
@@ -0,0 +1,30 @@
|
||||
name: 'Get Vault Secrets'
|
||||
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
|
||||
inputs:
|
||||
vault_host:
|
||||
description: 'Vault server URL'
|
||||
required: true
|
||||
vault_role_id:
|
||||
description: 'Vault AppRole Role ID'
|
||||
required: true
|
||||
vault_secret_id:
|
||||
description: 'Vault AppRole Secret ID'
|
||||
required: true
|
||||
secrets:
|
||||
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
|
||||
required: true
|
||||
default: |
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ inputs.vault_host }}
|
||||
method: approle
|
||||
roleId: ${{ inputs.vault_role_id }}
|
||||
secretId: ${{ inputs.vault_secret_id }}
|
||||
secrets: ${{ inputs.secrets }}
|
||||
@@ -0,0 +1,270 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
workflow_call:
|
||||
inputs:
|
||||
push_docker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
|
||||
|
||||
jobs:
|
||||
# Compute tags, version, and push decision for downstream jobs
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
tags: ${{ steps.build-info.outputs.tags }}
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
push: ${{ steps.build-info.outputs.push }}
|
||||
github_sha: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Initialize build info
|
||||
id: build-info
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef="${{ github.head_ref }}"
|
||||
githubRef="${{ github.ref }}"
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ -n "$inputVersion" ]]; then
|
||||
# Version provided via input (manual release)
|
||||
branch="main"
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag="$inputVersion"
|
||||
version="$inputVersion"
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
# Tag push
|
||||
branch=${githubRef##*/}
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
# Pull request
|
||||
branch=$githubHeadRef
|
||||
versionDockerTag="$branch"
|
||||
fi
|
||||
fi
|
||||
|
||||
githubTags=""
|
||||
if [ -n "$latestDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
|
||||
fi
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
githubTags="${githubTags#,}"
|
||||
|
||||
# Determine push decision
|
||||
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
|
||||
|
||||
echo "tags=$githubTags" >> $GITHUB_OUTPUT
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
|
||||
echo "branch=$branch" >> $GITHUB_OUTPUT
|
||||
echo "push=$push" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build each platform in parallel
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
id: build-push
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Build (no push)
|
||||
if: needs.prepare.outputs.push != 'true'
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Create multi-platform manifest and push with final tags
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
timeout-minutes: 5
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
tag_args=""
|
||||
IFS=',' read -ra TAG_ARRAY <<< "$tags"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
tag=$(echo "$tag" | xargs)
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args="$tag_args -t $tag"
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx imagetools create $tag_args \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
|
||||
docker buildx imagetools inspect "$first_tag"
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Build Executables
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
build-platform:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repoFullName=${{ github.repository }}
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Install dependencies and restore
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
run: |
|
||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||
|
||||
- name: Build ${{ matrix.platform }}
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained \
|
||||
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
- name: Zip artifact
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Build Frontend
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
retention-days: 1
|
||||
@@ -0,0 +1,376 @@
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-macos-installer:
|
||||
name: Build macOS ${{ matrix.arch }} Installer
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-15-intel
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-15
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
# For manual dispatch, use a default version
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Build macOS ${{ matrix.arch }} executable
|
||||
run: |
|
||||
# Clean any existing output directory
|
||||
rm -rf dist
|
||||
mkdir -p dist/temp
|
||||
|
||||
# Build to a temporary location
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugType=None \
|
||||
/p:DebugSymbols=false \
|
||||
/p:UseAppHost=true \
|
||||
/p:EnableMacOSCodeSign=false \
|
||||
/p:CodeSignOnCopy=false \
|
||||
/p:_CodeSignDuringBuild=false \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:TrimMode=link
|
||||
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS
|
||||
|
||||
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
|
||||
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Copy frontend directly to where it belongs in the app bundle
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
|
||||
|
||||
# Copy any additional runtime files if they exist
|
||||
if [ -d "dist/temp" ]; then
|
||||
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Post-build setup
|
||||
run: |
|
||||
# Make sure the executable is actually executable
|
||||
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Remove any .pdb files that might have been created
|
||||
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
|
||||
|
||||
echo "Checking architecture of built binary:"
|
||||
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
if command -v lipo >/dev/null 2>&1; then
|
||||
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
fi
|
||||
|
||||
echo "Files in MacOS directory:"
|
||||
ls -la dist/Cleanuparr.app/Contents/MacOS/
|
||||
|
||||
- name: Create macOS app bundle structure
|
||||
run: |
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
|
||||
|
||||
# Convert ICO to ICNS for macOS app bundle
|
||||
if command -v iconutil >/dev/null 2>&1; then
|
||||
# Create iconset directory structure
|
||||
mkdir -p Cleanuparr.iconset
|
||||
|
||||
# Use existing PNG files from Logo directory for different sizes
|
||||
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
|
||||
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
|
||||
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
|
||||
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
|
||||
|
||||
# Create ICNS file
|
||||
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
|
||||
|
||||
# Clean up iconset directory
|
||||
rm -rf Cleanuparr.iconset
|
||||
fi
|
||||
|
||||
# Create Launch Daemon plist
|
||||
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.cleanuparr.daemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/cleanuparr.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/cleanuparr.error.log</string>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HTTP_PORTS</key>
|
||||
<string>11011</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.Cleanuparr</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>CLNR</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${{ matrix.min_os_version }}</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
- name: Create PKG installer
|
||||
run: |
|
||||
# Create preinstall script to handle existing installations
|
||||
mkdir -p scripts
|
||||
cat > scripts/preinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and unload existing launch daemon if it exists
|
||||
if launchctl list | grep -q "com.cleanuparr.daemon"; then
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Stop any running instances of Cleanuparr
|
||||
pkill -f "Cleanuparr" || true
|
||||
sleep 2
|
||||
|
||||
# Remove old installation if it exists
|
||||
if [[ -d "/Applications/Cleanuparr.app" ]]; then
|
||||
rm -rf "/Applications/Cleanuparr.app"
|
||||
fi
|
||||
|
||||
# Remove old launch daemon plist if it exists
|
||||
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
|
||||
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/preinstall
|
||||
|
||||
# Create postinstall script
|
||||
cat > scripts/postinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Set proper permissions for the app bundle
|
||||
chmod -R 755 /Applications/Cleanuparr.app
|
||||
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Install the launch daemon
|
||||
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
|
||||
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
# Load and start the service
|
||||
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
launchctl start com.cleanuparr.daemon
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 3
|
||||
|
||||
# Display as system notification
|
||||
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/postinstall
|
||||
|
||||
# Create uninstall script (optional, for user reference)
|
||||
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Cleanuparr Uninstall Script
|
||||
# Run this script with sudo to completely remove Cleanuparr
|
||||
|
||||
echo "Stopping Cleanuparr service..."
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
|
||||
echo "Removing service files..."
|
||||
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
echo "Removing application..."
|
||||
rm -rf /Applications/Cleanuparr.app
|
||||
|
||||
echo "Removing logs..."
|
||||
rm -f /var/log/cleanuparr.log
|
||||
rm -f /var/log/cleanuparr.error.log
|
||||
|
||||
echo "Cleanuparr has been completely removed."
|
||||
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
|
||||
EOF
|
||||
|
||||
chmod +x scripts/uninstall_cleanuparr.sh
|
||||
|
||||
# Copy uninstall script to app bundle for user access
|
||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||
|
||||
# Determine package name - if app_version input was provided, it's a release build
|
||||
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
fi
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
--identifier com.Cleanuparr \
|
||||
--version ${{ env.appVersion }} \
|
||||
--install-location /Applications \
|
||||
--ownership preserve \
|
||||
${pkg_name}
|
||||
|
||||
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload installer as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,147 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
shell: pwsh
|
||||
run: |
|
||||
$repoFullName = "${{ github.repository }}"
|
||||
$ref = "${{ github.ref }}"
|
||||
$inputVersion = "${{ inputs.app_version }}"
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if ($inputVersion -ne "") {
|
||||
$appVersion = $inputVersion
|
||||
$releaseVersion = "v$appVersion"
|
||||
} elseif ($ref -match "^refs/tags/") {
|
||||
$releaseVersion = $ref -replace "refs/tags/", ""
|
||||
$appVersion = $releaseVersion -replace "^v", ""
|
||||
} else {
|
||||
# For manual dispatch, use a default version
|
||||
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
$appVersion = "0.0.1-dev"
|
||||
}
|
||||
|
||||
$repositoryName = $repoFullName.Split("/")[1]
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path "code/backend/${{ env.executableName }}/wwwroot"
|
||||
Copy-Item -Path "code/frontend/dist/ui/browser/*" -Destination "code/backend/${{ env.executableName }}/wwwroot/" -Recurse -Force
|
||||
|
||||
- name: Build Windows executable
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
|
||||
|
||||
- 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
|
||||
$innoPath = "C:\Program Files (x86)\Inno Setup 6"
|
||||
echo "$innoPath" >> $env:GITHUB_PATH
|
||||
|
||||
- name: Verify LICENSE file exists
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "LICENSE")) {
|
||||
Write-Error "LICENSE file not found in repository root"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "LICENSE file found successfully"
|
||||
|
||||
- name: Build Windows installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Copy installer script to root
|
||||
Copy-Item "installers/windows/cleanuparr-installer.iss" -Destination "cleanuparr-installer.iss"
|
||||
|
||||
# The installer script has been pre-updated with proper icon and config paths
|
||||
# No dynamic modifications needed as the base script now includes correct references
|
||||
|
||||
# Run Inno Setup compiler
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "cleanuparr-installer.iss"
|
||||
|
||||
# Check if installer was created
|
||||
if (Test-Path "installer/Cleanuparr_Setup.exe") {
|
||||
Write-Host "Installer created successfully"
|
||||
} else {
|
||||
Write-Error "Installer creation failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Rename installer with version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installerName = "Cleanuparr-${{ env.appVersion }}-Setup.exe"
|
||||
Move-Item "installer/Cleanuparr_Setup.exe" "installer/$installerName"
|
||||
echo "installerName=$installerName" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: installer/${{ env.installerName }}
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -1,12 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.build.app.yml@main
|
||||
with:
|
||||
dockerRepository: flaminel/cleanuperr
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
outputName: cleanuperr
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'Cloudflare/**'
|
||||
- 'blacklist'
|
||||
- 'blacklist_permissive'
|
||||
- 'whitelist'
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
mkdir -p Cloudflare/static
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
cp blacklist Cloudflare/static/
|
||||
cp blacklist_permissive Cloudflare/static/
|
||||
cp whitelist Cloudflare/static/
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Create status files
|
||||
run: |
|
||||
mkdir -p status
|
||||
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
|
||||
|
||||
# Cache static files for 10 minutes
|
||||
cat > status/_headers << 'EOF'
|
||||
/*
|
||||
Cache-Control: public, max-age=600, s-maxage=600
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "status"
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Dependency Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
# Fail on critical and high severity vulnerabilities
|
||||
fail-on-severity: high
|
||||
# Warn on moderate vulnerabilities
|
||||
warn-on-severity: moderate
|
||||
# Allow licenses
|
||||
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
|
||||
# Comment summarizes the vulnerabilities found
|
||||
comment-summary-in-pr: on-failure
|
||||
# Show dependency changes in PR
|
||||
show-openssf-scorecard: true
|
||||
vulnerability-check: true
|
||||
|
||||
- name: Upload dependency review results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dependency-review-results
|
||||
path: dependency-review-*.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
@@ -1,19 +0,0 @@
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'chart/**'
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: flmorg/universal-workflows/.github/workflows/chart.install.yml@main
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
chartRepo: oci://ghcr.io/flmorg
|
||||
chartName: universal-chart
|
||||
version: ^1.0.0
|
||||
valuesPath: chart/values.yaml
|
||||
releaseName: main
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.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: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/build
|
||||
retention-days: 1
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,94 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml up -d --build
|
||||
env:
|
||||
PACKAGES_USERNAME: ${{ github.repository_owner }}
|
||||
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install E2E dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Wait for Keycloak
|
||||
run: |
|
||||
echo "Waiting for Keycloak realm to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
|
||||
echo "Keycloak ready!"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
echo "Waiting for Cleanuparr to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
|
||||
echo "App ready!"
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml down
|
||||
@@ -1,20 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
# paths:
|
||||
# - 'code/**'
|
||||
# branches: [ main ]
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: flmorg/cleanuperr/.github/workflows/build.yml@main
|
||||
secrets: inherit
|
||||
|
||||
# deploy:
|
||||
# needs: [ build ]
|
||||
# uses: flmorg/cleanuperr/.github/workflows/deploy.yml@main
|
||||
# secrets: inherit
|
||||
@@ -1,11 +1,300 @@
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: true
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
buildDocker:
|
||||
description: 'Build Docker image'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
pushDocker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
buildBinaries:
|
||||
description: 'Build executables and installers'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
createRelease:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.release.yml@main
|
||||
# Validate release
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version=${GITHUB_REF##refs/tags/}
|
||||
app_version=${release_version#v}
|
||||
is_tag=true
|
||||
else
|
||||
# Manual workflow with version
|
||||
app_version="${{ github.event.inputs.version }}"
|
||||
|
||||
# Validate version format (x.x.x)
|
||||
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
|
||||
echo "Provided version: $app_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
fi
|
||||
|
||||
echo "app_version=$app_version" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Release Version: $release_version"
|
||||
echo "App Version: $app_version"
|
||||
echo "Is Tag: $is_tag"
|
||||
|
||||
- name: Check if release already exists
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
|
||||
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Run E2E tests
|
||||
e2e:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
secrets: inherit
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
create-release:
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
needs.build-executables.result == 'success' &&
|
||||
needs.build-windows-installer.result == 'success' &&
|
||||
needs.build-macos.result == 'success' &&
|
||||
(
|
||||
needs.validate.outputs.is_tag == 'true' ||
|
||||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download executable artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Windows installer
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download macOS installers
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: Cleanuparr-macos-*-installer
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
|
||||
echo ""
|
||||
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
|
||||
with:
|
||||
name: ${{ needs.validate.outputs.release_version }}
|
||||
tag_name: ${{ needs.validate.outputs.release_version }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
target_commitish: main
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./artifacts/*.zip
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Record workflow start time
|
||||
id: workflow-start
|
||||
run: |
|
||||
# Get workflow start time from GitHub API
|
||||
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
|
||||
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
|
||||
echo "start=$start_epoch" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
# Calculate total workflow duration
|
||||
start_time=${{ steps.workflow-start.outputs.start }}
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
minutes=$((duration / 60))
|
||||
seconds=$((duration % 60))
|
||||
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Helper function to print job result
|
||||
print_result() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "$result" in
|
||||
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
|
||||
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
|
||||
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "E2E Tests" "${{ needs.e2e.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
|
||||
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
workflow_call:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Restore dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/cleanuparr.sln
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
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
|
||||
with:
|
||||
name: test-results
|
||||
path: ./coverage/*.trx
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: ./coverage/**/coverage.cobertura.xml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage/**/coverage.cobertura.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
|
||||
- name: Test Summary
|
||||
run: |
|
||||
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
|
||||
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Get Version Info
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
manual_version:
|
||||
description: 'Manual version override (e.g., 1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
outputs:
|
||||
app_version:
|
||||
description: 'Application version (without v prefix)'
|
||||
value: ${{ jobs.version.outputs.app_version }}
|
||||
release_version:
|
||||
description: 'Release version (with v prefix)'
|
||||
value: ${{ jobs.version.outputs.release_version }}
|
||||
is_tag:
|
||||
description: 'Whether this is a tag event'
|
||||
value: ${{ jobs.version.outputs.is_tag }}
|
||||
repository_name:
|
||||
description: 'Repository name without owner'
|
||||
value: ${{ jobs.version.outputs.repository_name }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
repository_name: ${{ steps.version.outputs.repository_name }}
|
||||
|
||||
steps:
|
||||
- name: Calculate version info
|
||||
id: version
|
||||
run: |
|
||||
repoFullName="${{ github.repository }}"
|
||||
repositoryName="${repoFullName#*/}"
|
||||
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version="${GITHUB_REF##refs/tags/}"
|
||||
app_version="${release_version#v}"
|
||||
is_tag="true"
|
||||
elif [[ -n "${{ inputs.manual_version }}" ]]; then
|
||||
# Manual workflow with version
|
||||
app_version="${{ inputs.manual_version }}"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
else
|
||||
# Development build
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
fi
|
||||
|
||||
echo "app_version=${app_version}" >> $GITHUB_OUTPUT
|
||||
echo "release_version=${release_version}" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=${is_tag}" >> $GITHUB_OUTPUT
|
||||
echo "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "📦 Repository: ${repositoryName}"
|
||||
echo "🏷️ Release Version: ${release_version}"
|
||||
echo "📱 App Version: ${app_version}"
|
||||
echo "🔖 Is Tag: ${is_tag}"
|
||||
@@ -0,0 +1,350 @@
|
||||
# Cleanuparr - Claude AI Rules
|
||||
|
||||
## 🚨 Critical Guidelines
|
||||
|
||||
**READ THIS FIRST:**
|
||||
1. ⚠️ **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. ❓ **When in doubt, ASK** - Always clarify before implementing uncertain changes
|
||||
3. 📋 **Follow existing patterns** - Study the codebase style before making changes
|
||||
4. 🆕 **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
- Malware detection and blocking
|
||||
- Automatic search triggering after removal
|
||||
- Orphaned download cleanup with cross-seed support
|
||||
- Support for multiple notification providers (Discord, etc.)
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
### Backend
|
||||
- **.NET 10.0** (C#) with ASP.NET Core
|
||||
- **Architecture**: Clean Architecture pattern
|
||||
- `Cleanuparr.Domain` - Domain models and business logic
|
||||
- `Cleanuparr.Application` - Application services and use cases
|
||||
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
|
||||
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
|
||||
- `Cleanuparr.Api` - REST API and web host
|
||||
- `Cleanuparr.Shared` - Shared utilities
|
||||
- **Database**: SQLite with Entity Framework Core 10.0
|
||||
- Two separate contexts: `DataContext` and `EventsContext`
|
||||
- **Key Libraries**:
|
||||
- MassTransit (messaging)
|
||||
- Quartz.NET (scheduling)
|
||||
- Serilog (logging)
|
||||
- SignalR (real-time communication)
|
||||
|
||||
### Frontend
|
||||
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system (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)
|
||||
- **Real-time Updates**: SignalR (@microsoft/signalr)
|
||||
- **PWA**: Service Worker support enabled
|
||||
|
||||
### Documentation
|
||||
- **Docusaurus** (TypeScript-based static site)
|
||||
- Hosted at https://cleanuparr.github.io/Cleanuparr/
|
||||
|
||||
### Deployment
|
||||
- **Docker** (primary distribution method)
|
||||
- Standalone executables for Windows, macOS, and Linux
|
||||
- Platform installers for Windows (.exe) and macOS (.pkg)
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- .NET 10.0 SDK
|
||||
- Node.js 18+
|
||||
- Git
|
||||
- (Optional) Make for database migrations
|
||||
- (Optional) JetBrains Rider or Visual Studio
|
||||
|
||||
### GitHub Packages Authentication
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. Configure access:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
|
||||
You need a GitHub PAT with `read:packages` permission.
|
||||
|
||||
### Running the Backend
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
API runs at http://localhost:5000
|
||||
|
||||
### Running the Frontend
|
||||
```bash
|
||||
cd code/frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
UI runs at http://localhost:4200
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet test
|
||||
```
|
||||
|
||||
### Running Documentation
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
Docs run at http://localhost:3000
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # API entry point
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations
|
||||
│ │ ├── Cleanuparr.Persistence/ # Database & EF Core
|
||||
│ │ ├── Cleanuparr.Shared/ # Shared utilities
|
||||
│ │ └── *.Tests/ # Unit tests
|
||||
│ ├── frontend/ # Angular 21 application
|
||||
│ ├── ui/ # Built frontend assets
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── Logo/ # Branding assets
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns
|
||||
├── blacklist_permissive # Alternative blacklist
|
||||
├── whitelist # Safe file patterns
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Code Standards & Conventions
|
||||
|
||||
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||
|
||||
### Backend (C#)
|
||||
- Follow [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests for business logic
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- **Study existing service implementations before creating new ones**
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- Follow [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- All components must be **standalone** (no NgModules) with **ChangeDetectionStrategy.OnPush**
|
||||
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
|
||||
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
|
||||
- Follow the 3-layer SCSS design system (`_variables` → `_tokens` → `_themes`) for styling
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- Backend: xUnit or NUnit conventions
|
||||
- Frontend: Jasmine/Karma
|
||||
- **Test that existing functionality still works after changes**
|
||||
|
||||
### Git Commit Messages
|
||||
- Use clear, descriptive messages in imperative mood
|
||||
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||
|
||||
### Discovering Issues
|
||||
If you encounter potential gotchas, common mistakes, or areas that need special attention during development:
|
||||
- **Flag them to the maintainer immediately**
|
||||
- Document them if confirmed
|
||||
- Consider if they should be added to this guide
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts:
|
||||
- **DataContext**: Main application data
|
||||
- **EventsContext**: Event logging and audit trail
|
||||
|
||||
### Creating Migrations
|
||||
From the `code` directory:
|
||||
|
||||
```bash
|
||||
# Data migrations
|
||||
make migrate-data name=YourMigrationName
|
||||
|
||||
# Events migrations
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddDownloadClientConfig
|
||||
make migrate-events name=AddStrikeEvents
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New *arr Application Integration
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
|
||||
2. Update domain models in `Cleanuparr.Domain/`
|
||||
3. Create/update services in `Cleanuparr.Application/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/`
|
||||
5. Update frontend in `code/frontend/src/app/`
|
||||
6. Document in `docs/docs/`
|
||||
|
||||
### Adding a New Download Client
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/DownloadClients/`
|
||||
2. Follow existing patterns (qBittorrent, Transmission, etc.)
|
||||
3. Add configuration models to `Cleanuparr.Domain/`
|
||||
4. Update API and frontend as above
|
||||
|
||||
### Adding a New Notification Provider
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Notifications/`
|
||||
2. Update configuration models
|
||||
3. Add UI configuration in frontend
|
||||
4. Test with actual service
|
||||
|
||||
## Important Files
|
||||
|
||||
### Configuration Files
|
||||
- `code/backend/Cleanuparr.Api/appsettings.json` - Backend configuration
|
||||
- `code/frontend/angular.json` - Angular build configuration
|
||||
- `code/Dockerfile` - Docker multi-stage build
|
||||
- `docs/docusaurus.config.ts` - Documentation site config
|
||||
|
||||
### CI/CD Workflows
|
||||
- `.github/workflows/test.yml` - Run tests
|
||||
- `.github/workflows/build-docker.yml` - Build Docker images
|
||||
- `.github/workflows/build-executable.yml` - Build standalone executables
|
||||
- `.github/workflows/release.yml` - Create releases
|
||||
- `.github/workflows/docs.yml` - Deploy documentation
|
||||
|
||||
### Malware Protection
|
||||
- `blacklist` - Default malware file patterns (strict)
|
||||
- `blacklist_permissive` - Less strict patterns
|
||||
- `whitelist` - Known safe file extensions
|
||||
- `whitelist_with_subtitles` - Includes subtitle formats
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Before Starting Work
|
||||
1. **Announce your intent** - Comment on an issue or create a new one
|
||||
2. **Wait for approval** from maintainers
|
||||
3. Fork the repository and create a feature branch
|
||||
4. Make your changes following code standards
|
||||
5. Test thoroughly (both manual and automated tests)
|
||||
6. Submit a PR with clear description and testing notes
|
||||
|
||||
### Pull Request Requirements
|
||||
- Link to related issue
|
||||
- Clear description of changes
|
||||
- Evidence of testing
|
||||
- Updated documentation if needed
|
||||
- No breaking changes without discussion
|
||||
|
||||
## Docker Development
|
||||
|
||||
### Build Local Docker Image
|
||||
```bash
|
||||
cd code
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
### Multi-Architecture Build
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
When running via Docker:
|
||||
- `PORT` - API port (default: 11011)
|
||||
- `PUID` - User ID for file permissions
|
||||
- `PGID` - Group ID for file permissions
|
||||
- `TZ` - Timezone (e.g., `America/New_York`)
|
||||
|
||||
## Security & Safety
|
||||
|
||||
- Never commit sensitive data (API keys, tokens, passwords)
|
||||
- All *arr and download client credentials are stored encrypted
|
||||
- The malware detection system uses pattern matching on file extensions and names
|
||||
- Always validate user input on both frontend and backend
|
||||
- Follow OWASP guidelines for web application security
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Documentation**: https://cleanuparr.github.io/Cleanuparr/
|
||||
- **Discord**: https://discord.gg/SCtMCgtsc4
|
||||
- **GitHub Issues**: https://github.com/Cleanuparr/Cleanuparr/issues
|
||||
- **Releases**: https://github.com/Cleanuparr/Cleanuparr/releases
|
||||
|
||||
## Working with Claude - IMPORTANT
|
||||
|
||||
### Core Principles
|
||||
1. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
2. **Don't break existing functionality** - Everything is important and needs to work
|
||||
3. **Follow existing coding style** - Study the codebase patterns before making changes
|
||||
4. **Use current coding standards** - If you want to introduce something new, ask first
|
||||
|
||||
### When Modifying Code
|
||||
- **ALWAYS read existing files before suggesting changes**
|
||||
- Understand the current architecture and patterns
|
||||
- Prefer editing existing files over creating new ones
|
||||
- Follow the established conventions in the codebase exactly
|
||||
- Test changes locally when possible
|
||||
- **If you're unsure about an approach, ask before implementing**
|
||||
|
||||
### When Adding Features
|
||||
- Review similar existing features first to understand patterns
|
||||
- Maintain consistency with existing UI/UX patterns
|
||||
- Update both backend and frontend together
|
||||
- Add/update documentation
|
||||
- Consider backwards compatibility
|
||||
- **Ask about architectural decisions before implementing new patterns**
|
||||
|
||||
### When Fixing Bugs
|
||||
- Understand the root cause before proposing a fix
|
||||
- **Be careful not to break other functionality** - test related areas
|
||||
- Add tests to prevent regression
|
||||
- Update relevant documentation if behavior changes
|
||||
- Consider if other parts of the codebase might have similar issues
|
||||
- **Flag any potential gotchas or issues you discover**
|
||||
|
||||
## Notes
|
||||
|
||||
- The project uses **Clean Architecture** - respect layer boundaries
|
||||
- Database migrations require both contexts - don't forget EventsContext
|
||||
- Frontend uses a **custom glassmorphism design system** - don't introduce external UI frameworks (no PrimeNG, Material, etc.)
|
||||
- All frontend components are **standalone** with **OnPush** change detection
|
||||
- All downloads from *arr apps are processed through a **strike system**
|
||||
- The 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
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
# Contributing to Cleanuparr
|
||||
|
||||
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Announce Your Intent
|
||||
|
||||
Before starting any work, please let us know what you want to contribute:
|
||||
|
||||
- For existing issues: Comment on the issue stating you'd like to work on it
|
||||
- For new features/changes: Create a new issue first and mention that you want to work on it
|
||||
|
||||
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
|
||||
|
||||
**Wait for approval from the maintainers before proceeding with your contribution.**
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- [Node.js 18+](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/)
|
||||
|
||||
### Repository Setup
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
|
||||
cd Cleanuparr
|
||||
```
|
||||
3. Add the upstream repository:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
|
||||
```
|
||||
|
||||
## Backend Development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
#### 1. Create a GitHub Personal Access Token (PAT)
|
||||
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
|
||||
|
||||
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
|
||||
2. Click "Generate new token" → "Generate new token (classic)"
|
||||
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
|
||||
4. Set an expiration (recommend 90 days or longer for development)
|
||||
5. Select only the `read:packages` scope
|
||||
6. Click "Generate token" and copy it
|
||||
|
||||
#### 2. Configure NuGet Source
|
||||
|
||||
Add the Cleanuparr NuGet repository:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
|
||||
|
||||
### Running the Backend
|
||||
|
||||
#### Option 1: Using .NET CLI
|
||||
|
||||
Navigate to the backend directory:
|
||||
```bash
|
||||
cd code/backend
|
||||
```
|
||||
|
||||
Build the application:
|
||||
```bash
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run the application:
|
||||
```bash
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:5000
|
||||
|
||||
#### Option 2: Using an IDE
|
||||
|
||||
For JetBrains Rider or Visual Studio:
|
||||
1. Open the solution file: `code/backend/cleanuparr.sln`
|
||||
2. Set `Cleanuparr.Api` as the startup project
|
||||
3. Press `F5` to start the application
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Install Make if not already installed:
|
||||
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
|
||||
- macOS: Install via Homebrew (`brew install make`)
|
||||
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
|
||||
|
||||
#### Creating Migrations
|
||||
|
||||
From the `code` directory:
|
||||
|
||||
For data migrations (DataContext):
|
||||
```bash
|
||||
make migrate-data name=YourMigrationName
|
||||
```
|
||||
|
||||
For events migrations (EventsContext):
|
||||
```bash
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddUserPreferences
|
||||
make migrate-events name=AddAuditLogEvents
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd code/frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The UI will be available at http://localhost:4200
|
||||
|
||||
## Documentation Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the docs directory:
|
||||
```bash
|
||||
cd docs
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The documentation site will be available at http://localhost:3000
|
||||
|
||||
## Building with Docker
|
||||
|
||||
### Building a Local Docker Image
|
||||
|
||||
To build the Docker image locally for testing:
|
||||
|
||||
1. Navigate to the `code` directory:
|
||||
```bash
|
||||
cd code
|
||||
```
|
||||
|
||||
2. Build the image:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
|
||||
|
||||
3. Run the container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name cleanuparr-dev \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
cleanuparr:local
|
||||
```
|
||||
|
||||
4. Access the application at http://localhost:11011
|
||||
|
||||
### Building for Multiple Architectures
|
||||
|
||||
Use Docker Buildx for multi-platform builds:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (.NET/C#)
|
||||
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use meaningful variable and method names
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### Frontend (Angular/TypeScript)
|
||||
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### Documentation
|
||||
- Use clear, concise language
|
||||
- Include code examples where appropriate
|
||||
- Update relevant documentation when adding/changing features
|
||||
- Check for spelling and grammar
|
||||
|
||||
## Submitting Your Contribution
|
||||
|
||||
### 1. Create a Feature Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix-name
|
||||
```
|
||||
|
||||
### 2. Make Your Changes
|
||||
|
||||
- Write clean, well-documented code
|
||||
- Follow the code standards outlined above
|
||||
- **Test your changes thoroughly!**
|
||||
|
||||
### 3. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature: brief description of your changes"
|
||||
```
|
||||
|
||||
### 4. Keep Your Branch Updated
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### 5. Push to Your Fork
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
### 6. Create a Pull Request
|
||||
|
||||
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
|
||||
2. Click "New Pull Request"
|
||||
3. Select your fork and branch
|
||||
4. Fill out the PR template with:
|
||||
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
|
||||
- Description of changes
|
||||
- Related issue number
|
||||
- Testing performed
|
||||
- Screenshots (if applicable)
|
||||
|
||||
### 7. Code Review Process
|
||||
|
||||
- Maintainers will review your PR
|
||||
- Address any feedback or requested changes
|
||||
- Once approved, your PR will be merged
|
||||
|
||||
## Other Ways to Contribute
|
||||
|
||||
### Help Test New Features
|
||||
|
||||
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
|
||||
|
||||
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
|
||||
2. Let us know you're interested in testing
|
||||
3. We'll provide you with pre-release builds and testing instructions
|
||||
|
||||
Your feedback helps us catch issues early and deliver better releases.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
|
||||
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
|
||||
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
Thanks for contributing to Cleanuparr!
|
||||
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
File renamed without changes.
@@ -1,294 +1,113 @@
|
||||
# <img width="24px" src="./Logo/256.png" alt="cleanuperr"></img> cleanuperr
|
||||
_Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, cleanuperr can also trigger a search to replace the deleted shows/movies.
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||
cleanuperr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made cleanuperr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
The tool supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
|
||||
|
||||
Refer to the [Environment variables](#Environment-variables) section for detailed configuration instructions and the [Setup](#Setup) section for an in-depth explanation of the cleanup process.
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
## Key features
|
||||
- Marks unwanted files as skip/unwanted in the download client.
|
||||
- Automatically strikes stalled or stuck downloads.
|
||||
- Removes and blocks downloads that reached the maximum number of strikes or are marked as unwanted by the download client or by cleanuperr and triggers a search for removed downloads.
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
|
||||
|
||||
## Important note
|
||||
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version:
|
||||
- qBittorrent
|
||||
- Deluge
|
||||
- Transmission
|
||||
- Sonarr
|
||||
- Radarr
|
||||
- Lidarr
|
||||
> [!IMPORTANT]
|
||||
> **Features:**
|
||||
> - Strike system to mark bad downloads.
|
||||
> - Remove and block downloads that reached a maximum number of strikes.
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
This tool is actively developed and still a work in progress, so using the `latest` Docker tag may result in breaking changes. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together:
|
||||
## Sponsored by GitAds
|
||||
[](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
|
||||
|
||||
> https://discord.gg/sWggpnmGNY
|
||||
## Screenshots
|
||||
|
||||
# How it works
|
||||
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
|
||||
1. **Content blocker** will:
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Find the corresponding item from the download client for each queue item.
|
||||
- Mark the files that were found in the queue as **unwanted/skipped** if:
|
||||
- They **are listed in the blacklist**, or
|
||||
- They **are not included in the whitelist**.
|
||||
- If **all files** of a download **are unwanted**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- A new search will be triggered for the *arr item.
|
||||
2. **Queue cleaner** will:
|
||||
- Run every 5 minutes (or configured cron, or right after `content blocker`).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in matadata downloading** or **failed to be imported**.
|
||||
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
|
||||
- Check each queue item if it meets one of the following condition in the download client:
|
||||
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
|
||||
- All associated files of are marked as **unwanted/skipped**.
|
||||
- If the item **DOES NOT** match the above criteria, it will be skipped.
|
||||
- If the item **DOES** match the criteria or has received the **maximum number of strikes**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- A new search will be triggered for the *arr item.
|
||||
## 🎯 Supported Applications
|
||||
|
||||
# Setup
|
||||
### *Arr Applications (latest version)
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr v2**
|
||||
|
||||
## Using qBittorrent's built-in feature (works only with qBittorrent)
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
|
||||
1. Go to qBittorrent -> Options -> Downloads -> make sure `Excluded file names` is checked -> Paste an exclusion list that you have copied.
|
||||
- [blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), or
|
||||
- [permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive), or
|
||||
- create your own
|
||||
2. qBittorrent will block files from being downloaded. In the case of malicious content, **nothing is downloaded and the torrent is marked as complete**.
|
||||
3. Start **cleanuperr** with `QUEUECLEANER__ENABLED` set to `true`.
|
||||
4. The **queue cleaner** will perform a cleanup process as described in the [How it works](#how-it-works) section.
|
||||
### Platforms
|
||||
- **Docker**
|
||||
- **Windows**
|
||||
- **macOS**
|
||||
- **Linux**
|
||||
- **Unraid**
|
||||
|
||||
## Using cleanuperr's blocklist (works with all supported download clients)
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. Set both `QUEUECLEANER__ENABLED` and `CONTENTBLOCKER__ENABLED` to `true` in your environment variables.
|
||||
2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Arr variables](#Arr-variables) section.
|
||||
3. Once configured, cleanuperr will perform the following tasks:
|
||||
- Execute the **content blocker** job, as explained in the [How it works](#how-it-works) section.
|
||||
- Execute the **queue cleaner** job, as explained in the [How it works](#how-it-works) section.
|
||||
|
||||
## Using cleanuperr just for failed *arr imports (works for Usenet users as well)
|
||||
|
||||
1. Set `QUEUECLEANER__ENABLED` to `true`.
|
||||
2. Set `QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES` to a desired value.
|
||||
3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`.
|
||||
4. Set `DOWNLOAD_CLIENT` to `none`.
|
||||
|
||||
**No other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).**
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker compose yaml
|
||||
|
||||
```
|
||||
version: "3.3"
|
||||
services:
|
||||
cleanuperr:
|
||||
volumes:
|
||||
- ./cleanuperr/logs:/var/logs
|
||||
environment:
|
||||
- LOGGING__LOGLEVEL=Information
|
||||
- LOGGING__FILE__ENABLED=false
|
||||
- LOGGING__FILE__PATH=/var/logs/
|
||||
- LOGGING__ENHANCED=true
|
||||
|
||||
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
|
||||
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
|
||||
|
||||
- QUEUECLEANER__ENABLED=true
|
||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=false
|
||||
# - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=title mismatch
|
||||
# - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1=manual import required
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__STALLED_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__STALLED_DELETE_PRIVATE=false
|
||||
|
||||
- CONTENTBLOCKER__ENABLED=true
|
||||
- CONTENTBLOCKER__IGNORE_PRIVATE=false
|
||||
- CONTENTBLOCKER__DELETE_PRIVATE=false
|
||||
|
||||
- DOWNLOAD_CLIENT=none
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=qBittorrent
|
||||
# - QBITTORRENT__URL=http://localhost:8080
|
||||
# - QBITTORRENT__USERNAME=user
|
||||
# - QBITTORRENT__PASSWORD=pass
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=deluge
|
||||
# - DELUGE__URL=http://localhost:8112
|
||||
# - DELUGE__PASSWORD=testing
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=transmission
|
||||
# - TRANSMISSION__URL=http://localhost:9091
|
||||
# - TRANSMISSION__USERNAME=test
|
||||
# - TRANSMISSION__PASSWORD=testing
|
||||
|
||||
- SONARR__ENABLED=true
|
||||
- SONARR__SEARCHTYPE=Episode
|
||||
- SONARR__BLOCK__TYPE=blacklist
|
||||
- SONARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- SONARR__INSTANCES__0__URL=http://localhost:8989
|
||||
- SONARR__INSTANCES__0__APIKEY=secret1
|
||||
- SONARR__INSTANCES__1__URL=http://localhost:8990
|
||||
- SONARR__INSTANCES__1__APIKEY=secret2
|
||||
|
||||
- RADARR__ENABLED=true
|
||||
- RADARR__BLOCK__TYPE=blacklist
|
||||
- RADARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- RADARR__INSTANCES__0__URL=http://localhost:7878
|
||||
- RADARR__INSTANCES__0__APIKEY=secret3
|
||||
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
||||
- RADARR__INSTANCES__1__APIKEY=secret4
|
||||
|
||||
- LIDARR__ENABLED=true
|
||||
- LIDARR__BLOCK__TYPE=blacklist
|
||||
- LIDARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- LIDARR__INSTANCES__0__URL=http://radarr:8686
|
||||
- LIDARR__INSTANCES__0__APIKEY=secret5
|
||||
- LIDARR__INSTANCES__1__URL=http://radarr:8687
|
||||
- LIDARR__INSTANCES__1__APIKEY=secret6
|
||||
image: ghcr.io/flmorg/cleanuperr:latest
|
||||
restart: unless-stopped
|
||||
```bash
|
||||
docker run -d --name cleanuparr \
|
||||
--restart unless-stopped \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PORT=11011 \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
ghcr.io/cleanuparr/cleanuparr:latest
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
|
||||
|
||||
### General variables
|
||||
<details>
|
||||
<summary>Click here</summary>
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| LOGGING__LOGLEVEL | No | Can be `Verbose`, `Debug`, `Information`, `Warning`, `Error` or `Fatal`. | `Information` |
|
||||
| LOGGING__FILE__ENABLED | No | Enable or disable logging to file. | false |
|
||||
| LOGGING__FILE__PATH | No | Directory where to save the log files. | empty |
|
||||
| LOGGING__ENHANCED | No | Enhance logs whenever possible.<br>A more detailed description is provided. [here](variables.md#LOGGING__ENHANCED) | true |
|
||||
|||||
|
||||
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | - [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).<br>- Can be a max of 6h interval.<br>- **Is ignored if `QUEUECLEANER__RUNSEQUENTIALLY=true` and `CONTENTBLOCKER__ENABLED=true`**. | 0 0/5 * * * ? |
|
||||
| TRIGGERS__CONTENTBLOCKER | Yes if content blocker is enabled | - [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).<br>- Can be a max of 6h interval. | 0 0/5 * * * ? |
|
||||
|||||
|
||||
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner. | true |
|
||||
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process. | true |
|
||||
| QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | - After how many strikes should a failed import be removed.<br>- 0 means never. | 0 |
|
||||
| QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE | No | Whether to ignore failed imports from private trackers. | false |
|
||||
| QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE | No | - Whether to delete failed imports of private downloads from the download client.<br>- Does not have any effect if `QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE` is `true`.<br>- **Set this to `true` if you don't care about seeding, ratio, H&R and potentially losing your tracker account.** | false |
|
||||
| QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0 | No | - First pattern to look for when an import is failed.<br>- If the specified message pattern is found, the item is skipped. | empty |
|
||||
| QUEUECLEANER__STALLED_MAX_STRIKES | No | - After how many strikes should a stalled download be removed.<br>- 0 means never. | 0 |
|
||||
| QUEUECLEANER__STALLED_IGNORE_PRIVATE | No | Whether to ignore stalled downloads from private trackers. | false |
|
||||
| QUEUECLEANER__STALLED_DELETE_PRIVATE | No | - Whether to delete stalled private downloads from the download client.<br>- Does not have any effect if `QUEUECLEANER__STALLED_IGNORE_PRIVATE` is `true`.<br>- **Set this to `true` if you don't care about seeding, ratio, H&R and potentially losing your tracker account.** | false |
|
||||
|||||
|
||||
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker. | false |
|
||||
| CONTENTBLOCKER__IGNORE_PRIVATE | No | Whether to ignore downloads from private trackers. | false |
|
||||
| CONTENTBLOCKER__DELETE_PRIVATE | No | - Whether to delete private downloads that have all files blocked from the download client.<br>- Does not have any effect if `CONTENTBLOCKER__IGNORE_PRIVATE` is `true`.<br>- **Set this to `true` if you don't care about seeding, ratio, H&R and potentially losing your tracker account.** | false |
|
||||
</details>
|
||||
|
||||
### Download client variables
|
||||
<details>
|
||||
<summary>Click here</summary>
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| DOWNLOAD_CLIENT | No | Download client that is used by *arrs<br>Can be `qbittorrent`, `deluge`, `transmission` or `none` | `none` |
|
||||
| QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 |
|
||||
| QBITTORRENT__USERNAME | No | qBittorrent user | empty |
|
||||
| QBITTORRENT__PASSWORD | No | qBittorrent password | empty |
|
||||
|||||
|
||||
| DELUGE__URL | No | Deluge instance url | http://localhost:8080 |
|
||||
| DELUGE__PASSWORD | No | Deluge password | empty |
|
||||
|||||
|
||||
| TRANSMISSION__URL | No | Transmission instance url | http://localhost:9091 |
|
||||
| TRANSMISSION__USERNAME | No | Transmission user | empty |
|
||||
| TRANSMISSION__PASSWORD | No | Transmission password | empty |
|
||||
</details>
|
||||
|
||||
### Arr variables
|
||||
<details>
|
||||
<summary>Click here</summary>
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| SONARR__ENABLED | No | Enable or disable Sonarr cleanup | false |
|
||||
| SONARR__BLOCK__TYPE | No | Block type<br>Can be `blacklist` or `whitelist` | `blacklist` |
|
||||
| SONARR__BLOCK__PATH | No | Path to the blocklist (local file or url)<br>Needs to be json compatible | empty |
|
||||
| SONARR__SEARCHTYPE | No | What to search for after removing a queue item<br>Can be `Episode`, `Season` or `Series` | `Episode` |
|
||||
| SONARR__INSTANCES__0__URL | No | First Sonarr instance url | http://localhost:8989 |
|
||||
| SONARR__INSTANCES__0__APIKEY | No | First Sonarr instance API key | empty |
|
||||
|||||
|
||||
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
|
||||
| RADARR__BLOCK__TYPE | No | Block type<br>Can be `blacklist` or `whitelist` | `blacklist` |
|
||||
| RADARR__BLOCK__PATH | No | Path to the blocklist (local file or url)<br>Needs to be json compatible | empty |
|
||||
| RADARR__INSTANCES__0__URL | No | First Radarr instance url | http://localhost:7878 |
|
||||
| RADARR__INSTANCES__0__APIKEY | No | First Radarr instance API key | empty |
|
||||
|||||
|
||||
| LIDARR__ENABLED | No | Enable or disable LIDARR cleanup | false |
|
||||
| LIDARR__BLOCK__TYPE | No | Block type<br>Can be `blacklist` or `whitelist` | `blacklist` |
|
||||
| LIDARR__BLOCK__PATH | No | Path to the blocklist (local file or url)<br>Needs to be json compatible | empty |
|
||||
| LIDARR__INSTANCES__0__URL | No | First LIDARR instance url | http://localhost:8686 |
|
||||
| LIDARR__INSTANCES__0__APIKEY | No | First LIDARR instance API key | empty |
|
||||
</details>
|
||||
|
||||
### Advanced variables
|
||||
<details>
|
||||
<summary>Click here</summary>
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| HTTP_MAX_RETRIES | No | The number of times to retry a failed HTTP call (to *arrs, download clients etc.) | 0 |
|
||||
| HTTP_TIMEOUT | No | The number of seconds to wait before failing an HTTP call (to *arrs, download clients etc.) | 100 |
|
||||
</details>
|
||||
|
||||
#
|
||||
|
||||
### To be noted
|
||||
|
||||
1. The queue cleaner and content blocker can be enabled or disabled separately, if you want to run only one of them.
|
||||
2. Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of cleanuperr.
|
||||
3. The blocklists (blacklist/whitelist) should have a single pattern on each line and supports the following:
|
||||
After installation, open your browser and navigate to:
|
||||
```
|
||||
*example // file name ends with "example"
|
||||
example* // file name starts with "example"
|
||||
*example* // file name has "example" in the name
|
||||
example // file name is exactly the word "example"
|
||||
regex:<ANY_REGEX> // regex that needs to be marked at the start of the line with "regex:"
|
||||
http://localhost:11011
|
||||
```
|
||||
4. Multiple Sonarr/Radarr/Lidarr instances can be specified using this format, where `<NUMBER>` starts from `0`:
|
||||
```
|
||||
SONARR__INSTANCES__<NUMBER>__URL
|
||||
SONARR__INSTANCES__<NUMBER>__APIKEY
|
||||
```
|
||||
5. Multiple failed import patterns can be specified using this format, where `<NUMBER>` starts from 0:
|
||||
```
|
||||
QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>
|
||||
```
|
||||
6. [This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr, but they are not suitable for other *arrs.
|
||||
#
|
||||
|
||||
### Binaries (if you're not using Docker)
|
||||
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
|
||||
|
||||
1. Download the binaries from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract them from the zip file.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [above](#environment-variables).
|
||||
## 📖 Documentation & Support
|
||||
|
||||
### Run as a Windows Service
|
||||
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
|
||||
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
|
||||
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
|
||||
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
|
||||
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
|
||||
|
||||
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
|
||||
|
||||
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
|
||||
|
||||
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
|
||||
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
- [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr)
|
||||
- [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner)
|
||||
- [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr) for the logo
|
||||
- [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr)
|
||||
|
||||
# Buy me a coffee
|
||||
If I made your life just a tiny bit easier, consider buying me a coffee!
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
*(sample).*
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
*.000
|
||||
*.001
|
||||
*.002
|
||||
*.004
|
||||
*.0xe
|
||||
*.73k
|
||||
*.73p
|
||||
*.7z
|
||||
*.7z.001
|
||||
*.7z.002
|
||||
*.89k
|
||||
*.89z
|
||||
*.8ck
|
||||
*.a00
|
||||
*.a01
|
||||
*.a02
|
||||
*.a7r
|
||||
*.ac
|
||||
*.acc
|
||||
@@ -21,8 +37,11 @@
|
||||
*.ahk
|
||||
*.ai
|
||||
*.aif
|
||||
*.ain
|
||||
*.air
|
||||
*.alz
|
||||
*.ana
|
||||
*.apex
|
||||
*.api
|
||||
*.apk
|
||||
*.app
|
||||
@@ -30,15 +49,28 @@
|
||||
*.applescript
|
||||
*.application
|
||||
*.appx
|
||||
*.apz
|
||||
*.ar
|
||||
*.arc
|
||||
*.archiver
|
||||
*.arduboy
|
||||
*.arh
|
||||
*.ari
|
||||
*.arj
|
||||
*.ark
|
||||
*.arscript
|
||||
*.asb
|
||||
*.asice
|
||||
*.asp
|
||||
*.aspx
|
||||
*.aspx-exe
|
||||
*.atmx
|
||||
*.ayt
|
||||
*.azw2
|
||||
*.b1
|
||||
*.b6z
|
||||
*.b64
|
||||
*.ba
|
||||
*.ba_
|
||||
*.bak
|
||||
*.bas
|
||||
@@ -46,26 +78,48 @@
|
||||
*.bat
|
||||
*.bdjo
|
||||
*.bdmv
|
||||
*.bdoc
|
||||
*.beam
|
||||
*.bh
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bms
|
||||
*.bndl
|
||||
*.bns
|
||||
*.boo
|
||||
*.bsa
|
||||
*.btm
|
||||
*.bundle
|
||||
*.bz
|
||||
*.bz2
|
||||
*.bza
|
||||
*.bzabw
|
||||
*.bzip
|
||||
*.bzip2
|
||||
*.c
|
||||
*.c00
|
||||
*.c01
|
||||
*.c02
|
||||
*.c10
|
||||
*.cab
|
||||
*.caction
|
||||
*.car
|
||||
*.cb7
|
||||
*.cba
|
||||
*.cbr
|
||||
*.cbt
|
||||
*.cbz
|
||||
*.cci
|
||||
*.cda
|
||||
*.cdb
|
||||
*.cdz
|
||||
*.cel
|
||||
*.celx
|
||||
*.cfs
|
||||
*.cgi
|
||||
*.cheat
|
||||
*.chm
|
||||
*.cit
|
||||
*.ckpt
|
||||
*.cla
|
||||
*.class
|
||||
@@ -75,9 +129,15 @@
|
||||
*.coffee
|
||||
*.com
|
||||
*.command
|
||||
*.comppkg.hauptwerk.rar
|
||||
*.comppkg_hauptwerk_rar
|
||||
*.conda
|
||||
*.conf
|
||||
*.config
|
||||
*.cp9
|
||||
*.cpgz
|
||||
*.cpl
|
||||
*.cpt
|
||||
*.crt
|
||||
*.cs
|
||||
*.csh
|
||||
@@ -85,17 +145,27 @@
|
||||
*.csproj
|
||||
*.css
|
||||
*.csv
|
||||
*.ctx
|
||||
*.ctz
|
||||
*.cue
|
||||
*.cur
|
||||
*.cxarchive
|
||||
*.cyw
|
||||
*.czip
|
||||
*.daemon
|
||||
*.daf
|
||||
*.dar
|
||||
*.dat
|
||||
*.data-00000-of-00001
|
||||
*.db
|
||||
*.dd
|
||||
*.deamon
|
||||
*.deb
|
||||
*.dek
|
||||
*.dgc
|
||||
*.dist
|
||||
*.diz
|
||||
*.dl_
|
||||
*.dld
|
||||
*.dll
|
||||
*.dmc
|
||||
@@ -112,19 +182,27 @@
|
||||
*.dw
|
||||
*.dword
|
||||
*.dxl
|
||||
*.dz
|
||||
*.e_e
|
||||
*.ear
|
||||
*.ebacmd
|
||||
*.ebm
|
||||
*.ebs
|
||||
*.ebs2
|
||||
*.ecar
|
||||
*.ecf
|
||||
*.ecs
|
||||
*.ecsbx
|
||||
*.edz
|
||||
*.efw
|
||||
*.egg
|
||||
*.eham
|
||||
*.elf
|
||||
*.elf-so
|
||||
*.email
|
||||
*.emu
|
||||
*.epk
|
||||
*.epi
|
||||
*.es
|
||||
*.esh
|
||||
*.etc
|
||||
@@ -140,36 +218,62 @@
|
||||
*.exz
|
||||
*.ezs
|
||||
*.ezt
|
||||
*.f
|
||||
*.f3z
|
||||
*.fas
|
||||
*.fba
|
||||
*.fcx
|
||||
*.fky
|
||||
*.flac
|
||||
*.flatpak
|
||||
*.flv
|
||||
*.fp8
|
||||
*.fpi
|
||||
*.frs
|
||||
*.fxp
|
||||
*.fzpz
|
||||
*.gadget
|
||||
*.gar
|
||||
*.gat
|
||||
*.gca
|
||||
*.gif
|
||||
*.gifv
|
||||
*.gm9
|
||||
*.gmz
|
||||
*.gpe
|
||||
*.gpu
|
||||
*.gs
|
||||
*.gz
|
||||
*.gz2
|
||||
*.gza
|
||||
*.gzi
|
||||
*.gzip
|
||||
*.h5
|
||||
*.ha
|
||||
*.ham
|
||||
*.hbc
|
||||
*.hbc2
|
||||
*.hbe
|
||||
*.hex
|
||||
*.hki
|
||||
*.hki1
|
||||
*.hki2
|
||||
*.hki3
|
||||
*.hlp
|
||||
*.hms
|
||||
*.hpf
|
||||
*.hpk
|
||||
*.hpkg
|
||||
*.hta
|
||||
*.hta-psh
|
||||
*.htaccess
|
||||
*.htm
|
||||
*.html
|
||||
*.htmi
|
||||
*.hyp
|
||||
*.iadproj
|
||||
*.icd
|
||||
*.ice
|
||||
*.icns
|
||||
*.ico
|
||||
*.idx
|
||||
@@ -182,17 +286,27 @@
|
||||
*.ins
|
||||
*.ipa
|
||||
*.ipf
|
||||
*.ipg
|
||||
*.ipk
|
||||
*.ipsw
|
||||
*.iqylink
|
||||
*.ish
|
||||
*.iso
|
||||
*.isp
|
||||
*.isu
|
||||
*.isx
|
||||
*.ita
|
||||
*.ize
|
||||
*.izh
|
||||
*.izma ace
|
||||
*.j
|
||||
*.jar
|
||||
*.jar.pack
|
||||
*.java
|
||||
*.jex
|
||||
*.jgz
|
||||
*.jhh
|
||||
*.jic
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.js
|
||||
@@ -201,27 +315,51 @@
|
||||
*.jse
|
||||
*.jsf
|
||||
*.json
|
||||
*.jsonlz4
|
||||
*.jsp
|
||||
*.jsx
|
||||
*.kextraction
|
||||
*.kgb
|
||||
*.kix
|
||||
*.ksh
|
||||
*.ksp
|
||||
*.kwgt
|
||||
*.kx
|
||||
*.kz
|
||||
*.layout
|
||||
*.lbr
|
||||
*.lck
|
||||
*.ldb
|
||||
*.lemon
|
||||
*.lha
|
||||
*.lhzd
|
||||
*.lib
|
||||
*.libzip
|
||||
*.link
|
||||
*.lnk
|
||||
*.lo
|
||||
*.lock
|
||||
*.log
|
||||
*.loop-vbs
|
||||
*.lpkg
|
||||
*.lqr
|
||||
*.ls
|
||||
*.lz
|
||||
*.lz4
|
||||
*.lzh
|
||||
*.lzm
|
||||
*.lzma
|
||||
*.lzo
|
||||
*.lzr
|
||||
*.lzx
|
||||
*.m3u
|
||||
*.m4a
|
||||
*.mac
|
||||
*.macho
|
||||
*.mamc
|
||||
*.manifest
|
||||
*.mar
|
||||
*.mbz
|
||||
*.mcr
|
||||
*.md
|
||||
*.mda
|
||||
@@ -232,22 +370,29 @@
|
||||
*.mdt
|
||||
*.mel
|
||||
*.mem
|
||||
*.memo
|
||||
*.meta
|
||||
*.mgm
|
||||
*.mhm
|
||||
*.mht
|
||||
*.mhtml
|
||||
*.mid
|
||||
*.mint
|
||||
*.mio
|
||||
*.mlappinstall
|
||||
*.mlproj
|
||||
*.mlx
|
||||
*.mm
|
||||
*.mobileconfig
|
||||
*.model
|
||||
*.moo
|
||||
*.mou
|
||||
*.movpkg
|
||||
*.mozlz4
|
||||
*.mp3
|
||||
*.mpa
|
||||
*.mpk
|
||||
*.mpkg
|
||||
*.mpls
|
||||
*.mrc
|
||||
*.mrp
|
||||
@@ -266,41 +411,79 @@
|
||||
*.msp
|
||||
*.mst
|
||||
*.msu
|
||||
*.mxc
|
||||
*.mxe
|
||||
*.mzp
|
||||
*.n
|
||||
*.nar
|
||||
*.ncl
|
||||
*.net
|
||||
*.nex
|
||||
*.nexe
|
||||
*.nfo
|
||||
*.npk
|
||||
*.nrg
|
||||
*.num
|
||||
*.nz
|
||||
*.nzb.bz2
|
||||
*.nzb.gz
|
||||
*.nzbs
|
||||
*.oar
|
||||
*.ocx
|
||||
*.odlgz
|
||||
*.odt
|
||||
*.opk
|
||||
*.ore
|
||||
*.osf
|
||||
*.ost
|
||||
*.osx
|
||||
*.osx-app
|
||||
*.otm
|
||||
*.out
|
||||
*.ova
|
||||
*.oz
|
||||
*.p
|
||||
*.p01
|
||||
*.p19
|
||||
*.p7z
|
||||
*.pa
|
||||
*.pack.gz
|
||||
*.package
|
||||
*.pae
|
||||
*.paf
|
||||
*.pak
|
||||
*.paq6
|
||||
*.paq7
|
||||
*.paq8
|
||||
*.paq8f
|
||||
*.paq8l
|
||||
*.paq8p
|
||||
*.par
|
||||
*.par2
|
||||
*.pax
|
||||
*.pb
|
||||
*.pbi
|
||||
*.pcd
|
||||
*.pcv
|
||||
*.pdb
|
||||
*.pdf
|
||||
*.pea
|
||||
*.perl
|
||||
*.pet
|
||||
*.pex
|
||||
*.pf
|
||||
*.phar
|
||||
*.php
|
||||
*.php5
|
||||
*.pif
|
||||
*.pim
|
||||
*.pima
|
||||
*.pit
|
||||
*.piz
|
||||
*.pkg
|
||||
*.pkg.tar.xz
|
||||
*.pkg.tar.zst
|
||||
*.pkz
|
||||
*.pl
|
||||
*.plsc
|
||||
*.plx
|
||||
@@ -318,6 +501,7 @@
|
||||
*.pptx
|
||||
*.prc
|
||||
*.prg
|
||||
*.prs
|
||||
*.ps
|
||||
*.ps1
|
||||
*.ps1xml
|
||||
@@ -333,9 +517,16 @@
|
||||
*.psh-reflection
|
||||
*.psm1
|
||||
*.pst
|
||||
*.psz
|
||||
*.pt
|
||||
*.pup
|
||||
*.puz
|
||||
*.pvd
|
||||
*.pvmp
|
||||
*.pvmz
|
||||
*.pwa
|
||||
*.pwc
|
||||
*.pxl
|
||||
*.pxo
|
||||
*.py
|
||||
*.pyc
|
||||
@@ -343,8 +534,20 @@
|
||||
*.pyo
|
||||
*.python
|
||||
*.pyz
|
||||
*.q
|
||||
*.qda
|
||||
*.qit
|
||||
*.qpx
|
||||
*.r0
|
||||
*.r00
|
||||
*.r01
|
||||
*.r02
|
||||
*.r03
|
||||
*.r04
|
||||
*.r1
|
||||
*.r2
|
||||
*.r21
|
||||
*.r30
|
||||
*.ram
|
||||
*.rar
|
||||
*.raw
|
||||
@@ -355,22 +558,35 @@
|
||||
*.reg
|
||||
*.resources
|
||||
*.resx
|
||||
*.rev
|
||||
*.rfs
|
||||
*.rfu
|
||||
*.rgs
|
||||
*.rk
|
||||
*.rm
|
||||
*.rnc
|
||||
*.rox
|
||||
*.rp9
|
||||
*.rpg
|
||||
*.rpj
|
||||
*.rpm
|
||||
*.rss
|
||||
*.ruby
|
||||
*.run
|
||||
*.rxe
|
||||
*.rz
|
||||
*.s00
|
||||
*.s01
|
||||
*.s02
|
||||
*.s09
|
||||
*.s2a
|
||||
*.s7z
|
||||
*.sample
|
||||
*.sapk
|
||||
*.sar
|
||||
*.savedmodel
|
||||
*.sbs
|
||||
*.sbx
|
||||
*.sca
|
||||
*.scar
|
||||
*.scb
|
||||
@@ -380,42 +596,86 @@
|
||||
*.scr
|
||||
*.script
|
||||
*.sct
|
||||
*.sdc
|
||||
*.sdn
|
||||
*.sdoc
|
||||
*.sdocx
|
||||
*.sea
|
||||
*.seed
|
||||
*.sen
|
||||
*.server
|
||||
*.service
|
||||
*.sfg
|
||||
*.sfm
|
||||
*.sfs
|
||||
*.sfv
|
||||
*.sfx
|
||||
*.sh
|
||||
*.shar
|
||||
*.shb
|
||||
*.shell
|
||||
*.shk
|
||||
*.shortcut
|
||||
*.shr
|
||||
*.shs
|
||||
*.shtml
|
||||
*.sifz
|
||||
*.sipa
|
||||
*.sit
|
||||
*.sitx
|
||||
*.sk
|
||||
*.sldm
|
||||
*.sln
|
||||
*.smm
|
||||
*.smpf
|
||||
*.snap
|
||||
*.snagitstamps
|
||||
*.snappy
|
||||
*.snb
|
||||
*.snd
|
||||
*.snz
|
||||
*.spa
|
||||
*.spd
|
||||
*.spl
|
||||
*.spm
|
||||
*.spr
|
||||
*.spt
|
||||
*.sql
|
||||
*.sqf
|
||||
*.sqx
|
||||
*.sqz
|
||||
*.srec
|
||||
*.srep
|
||||
*.srt
|
||||
*.ssm
|
||||
*.stg
|
||||
*.stkdoodlz
|
||||
*.stproj
|
||||
*.sts
|
||||
*.sub
|
||||
*.svg
|
||||
*.swf
|
||||
*.sy_
|
||||
*.sys
|
||||
*.tar
|
||||
*.tar.bz2
|
||||
*.tar.gz
|
||||
*.tar.gz2
|
||||
*.tar.lz
|
||||
*.tar.lzma
|
||||
*.tar.xz
|
||||
*.tar.z
|
||||
*.tar.zip
|
||||
*.taz
|
||||
*.tbl
|
||||
*.tbz
|
||||
*.tbz2
|
||||
*.tcp
|
||||
*.tcx
|
||||
*.text
|
||||
*.tf
|
||||
*.tg
|
||||
*.tgs
|
||||
*.tgz
|
||||
*.thm
|
||||
*.thmx
|
||||
@@ -424,19 +684,35 @@
|
||||
*.tif
|
||||
*.tiff
|
||||
*.tipa
|
||||
*.tlz
|
||||
*.tlzma
|
||||
*.tmp
|
||||
*.tms
|
||||
*.toast
|
||||
*.torrent
|
||||
*.tpk
|
||||
*.tpsr
|
||||
*.trs
|
||||
*.txt
|
||||
*.tx_
|
||||
*.txz
|
||||
*.tz
|
||||
*.tzst
|
||||
*.u3p
|
||||
*.ubz
|
||||
*.uc2
|
||||
*.udf
|
||||
*.ufdr
|
||||
*.ufs.uzip
|
||||
*.uha
|
||||
*.upk
|
||||
*.upx
|
||||
*.url
|
||||
*.uue
|
||||
*.uvm
|
||||
*.uw8
|
||||
*.uzed
|
||||
*.uzip
|
||||
*.vb
|
||||
*.vba
|
||||
*.vba-exe
|
||||
@@ -448,26 +724,46 @@
|
||||
*.vbscript
|
||||
*.vcd
|
||||
*.vdo
|
||||
*.vem
|
||||
*.vexe
|
||||
*.vfs
|
||||
*.vhd
|
||||
*.vhdx
|
||||
*.vib
|
||||
*.vip
|
||||
*.vlx
|
||||
*.vm
|
||||
*.vmcz
|
||||
*.vmdk
|
||||
*.vms
|
||||
*.vob
|
||||
*.vocab
|
||||
*.voca
|
||||
*.vpk
|
||||
*.vpm
|
||||
*.vrpackage
|
||||
*.vsi
|
||||
*.vwi
|
||||
*.vxp
|
||||
*.wa
|
||||
*.wacz
|
||||
*.waff
|
||||
*.war
|
||||
*.wastickers
|
||||
*.wav
|
||||
*.wbk
|
||||
*.wcm
|
||||
*.wdz
|
||||
*.webm
|
||||
*.whl
|
||||
*.wick
|
||||
*.widget
|
||||
*.wim
|
||||
*.wiz
|
||||
*.wlb
|
||||
*.wma
|
||||
*.workflow
|
||||
*.wot
|
||||
*.wpk
|
||||
*.wpl
|
||||
*.wpm
|
||||
@@ -476,14 +772,26 @@
|
||||
*.wsc
|
||||
*.wsf
|
||||
*.wsh
|
||||
*.wux
|
||||
*.x86
|
||||
*.x86_64
|
||||
*.xaml
|
||||
*.xap
|
||||
*.xapk
|
||||
*.xar
|
||||
*.xbap
|
||||
*.xbe
|
||||
*.xcf.bz2
|
||||
*.xcf.gz
|
||||
*.xcf.xz
|
||||
*.xcfbz2
|
||||
*.xcfgz
|
||||
*.xcfxz
|
||||
*.xex
|
||||
*.xez
|
||||
*.xfp
|
||||
*.xig
|
||||
*.xip
|
||||
*.xla
|
||||
*.xlam
|
||||
*.xll
|
||||
@@ -496,24 +804,47 @@
|
||||
*.xltb
|
||||
*.xltm
|
||||
*.xlw
|
||||
*.xmcdz
|
||||
*.xml
|
||||
*.xoj
|
||||
*.xopp
|
||||
*.xqt
|
||||
*.xrt
|
||||
*.xx
|
||||
*.xys
|
||||
*.xz
|
||||
*.xzm
|
||||
*.y
|
||||
*.yc
|
||||
*.ygh
|
||||
*.yz1
|
||||
*.z
|
||||
*.z00
|
||||
*.z01
|
||||
*.z02
|
||||
*.z03
|
||||
*.z04
|
||||
*.zabw
|
||||
*.zap
|
||||
*.zed
|
||||
*.zfsendtotarget
|
||||
*.zhelp
|
||||
*.zi
|
||||
*.zi_
|
||||
*.zim
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zix
|
||||
*.zl
|
||||
*.zl9
|
||||
*.zoo
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
*.zpaq
|
||||
*.zpi
|
||||
*.zsplit
|
||||
*.zst
|
||||
*.zw
|
||||
*.zwi
|
||||
*.zz
|
||||
Trailer.*
|
||||
VOSTFR
|
||||
api
|
||||
+360
-1
@@ -1,51 +1,410 @@
|
||||
*.000
|
||||
*.001
|
||||
*.002
|
||||
*.004
|
||||
*.7z
|
||||
*.7z.001
|
||||
*.7z.002
|
||||
*.a00
|
||||
*.a01
|
||||
*.a02
|
||||
*.ace
|
||||
*.ain
|
||||
*.alz
|
||||
*.ana
|
||||
*.apex
|
||||
*.apk
|
||||
*.apz
|
||||
*.ar
|
||||
*.arc
|
||||
*.archiver
|
||||
*.arduboy
|
||||
*.arh
|
||||
*.ari
|
||||
*.arj
|
||||
*.ark
|
||||
*.asice
|
||||
*.ayt
|
||||
*.b1
|
||||
*.b6z
|
||||
*.b64
|
||||
*.ba
|
||||
*.bat
|
||||
*.bdoc
|
||||
*.bh
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bndl
|
||||
*.boo
|
||||
*.bundle
|
||||
*.bz
|
||||
*.bz2
|
||||
*.bza
|
||||
*.bzabw
|
||||
*.bzip
|
||||
*.bzip2
|
||||
*.c00
|
||||
*.c01
|
||||
*.c02
|
||||
*.c10
|
||||
*.car
|
||||
*.cb7
|
||||
*.cba
|
||||
*.cbr
|
||||
*.cbt
|
||||
*.cbz
|
||||
*.cdz
|
||||
*.cit
|
||||
*.cmd
|
||||
*.com
|
||||
*.comppkg.hauptwerk.rar
|
||||
*.comppkg_hauptwerk_rar
|
||||
*.conda
|
||||
*.cp9
|
||||
*.cpgz
|
||||
*.cpt
|
||||
*.ctx
|
||||
*.ctz
|
||||
*.cxarchive
|
||||
*.czip
|
||||
*.daf
|
||||
*.dar
|
||||
*.db
|
||||
*.dd
|
||||
*.deb
|
||||
*.dgc
|
||||
*.dist
|
||||
*.diz
|
||||
*.dl_
|
||||
*.dll
|
||||
*.dmg
|
||||
*.dz
|
||||
*.ecar
|
||||
*.ecs
|
||||
*.ecsbx
|
||||
*.edz
|
||||
*.efw
|
||||
*.egg
|
||||
*.epi
|
||||
*.etc
|
||||
*.exe
|
||||
*.f
|
||||
*.f3z
|
||||
*.fcx
|
||||
*.fp8
|
||||
*.fzpz
|
||||
*.gar
|
||||
*.gca
|
||||
*.gif
|
||||
*.gmz
|
||||
*.gz
|
||||
*.gz2
|
||||
*.gza
|
||||
*.gzi
|
||||
*.gzip
|
||||
*.ha
|
||||
*.hbc
|
||||
*.hbc2
|
||||
*.hbe
|
||||
*.hki
|
||||
*.hki1
|
||||
*.hki2
|
||||
*.hki3
|
||||
*.hpk
|
||||
*.hpkg
|
||||
*.htm
|
||||
*.html
|
||||
*.htmi
|
||||
*.hyp
|
||||
*.iadproj
|
||||
*.ice
|
||||
*.ico
|
||||
*.ini
|
||||
*.ipg
|
||||
*.ipk
|
||||
*.ish
|
||||
*.iso
|
||||
*.isx
|
||||
*.ita
|
||||
*.ize
|
||||
*.j
|
||||
*.jar
|
||||
*.jar.pack
|
||||
*.jex
|
||||
*.jgz
|
||||
*.jhh
|
||||
*.jic
|
||||
*.jpg
|
||||
*.js
|
||||
*.jsonlz4
|
||||
*.kextraction
|
||||
*.kgb
|
||||
*.ksp
|
||||
*.kwgt
|
||||
*.kz
|
||||
*.layout
|
||||
*.lbr
|
||||
*.lemon
|
||||
*.lha
|
||||
*.lhzd
|
||||
*.libzip
|
||||
*.link
|
||||
*.lnk
|
||||
*.lpkg
|
||||
*.lqr
|
||||
*.lz
|
||||
*.lz4
|
||||
*.lzh
|
||||
*.lzm
|
||||
*.lzma
|
||||
*.lzo
|
||||
*.lzr
|
||||
*.lzx
|
||||
*.mar
|
||||
*.mbz
|
||||
*.md
|
||||
*.memo
|
||||
*.mint
|
||||
*.mlproj
|
||||
*.mou
|
||||
*.movpkg
|
||||
*.mozlz4
|
||||
*.mpkg
|
||||
*.msi
|
||||
*.mxc
|
||||
*.mzp
|
||||
*.nar
|
||||
*.nex
|
||||
*.nfo
|
||||
*.npk
|
||||
*.nz
|
||||
*.oar
|
||||
*.odlgz
|
||||
*.opk
|
||||
*.osf
|
||||
*.oz
|
||||
*.p01
|
||||
*.p19
|
||||
*.p7z
|
||||
*.pa
|
||||
*.pack.gz
|
||||
*.package
|
||||
*.pae
|
||||
*.pak
|
||||
*.paq6
|
||||
*.paq7
|
||||
*.paq8
|
||||
*.paq8f
|
||||
*.paq8l
|
||||
*.paq8p
|
||||
*.par
|
||||
*.par2
|
||||
*.pax
|
||||
*.pbi
|
||||
*.pcv
|
||||
*.pea
|
||||
*.perl
|
||||
*.pet
|
||||
*.pf
|
||||
*.php
|
||||
*.pim
|
||||
*.pima
|
||||
*.pit
|
||||
*.piz
|
||||
*.pkg
|
||||
*.pkg.tar.xz
|
||||
*.pkg.tar.zst
|
||||
*.pkz
|
||||
*.pl
|
||||
*.png
|
||||
*.prs
|
||||
*.ps1
|
||||
*.psc1
|
||||
*.psd1
|
||||
*.psm1
|
||||
*.psz
|
||||
*.pup
|
||||
*.puz
|
||||
*.pvmp
|
||||
*.pvmz
|
||||
*.pwa
|
||||
*.pxl
|
||||
*.py
|
||||
*.pyd
|
||||
*.q
|
||||
*.qda
|
||||
*.r0
|
||||
*.r00
|
||||
*.r01
|
||||
*.r02
|
||||
*.r03
|
||||
*.r04
|
||||
*.r1
|
||||
*.r2
|
||||
*.r21
|
||||
*.r30
|
||||
*.rar
|
||||
*.rb
|
||||
*.readme
|
||||
*.reg
|
||||
*.rev
|
||||
*.rk
|
||||
*.rnc
|
||||
*.rp9
|
||||
*.rpm
|
||||
*.rss
|
||||
*.run
|
||||
*.rz
|
||||
*.s00
|
||||
*.s01
|
||||
*.s02
|
||||
*.s09
|
||||
*.s7z
|
||||
*.sar
|
||||
*.sbx
|
||||
*.scr
|
||||
*.sdc
|
||||
*.sdn
|
||||
*.sdoc
|
||||
*.sdocx
|
||||
*.sea
|
||||
*.sen
|
||||
*.sfg
|
||||
*.sfm
|
||||
*.sfs
|
||||
*.sfx
|
||||
*.sh
|
||||
*.shar
|
||||
*.shk
|
||||
*.shr
|
||||
*.sifz
|
||||
*.sipa
|
||||
*.sit
|
||||
*.sitx
|
||||
*.smpf
|
||||
*.snagitstamps
|
||||
*.snappy
|
||||
*.snb
|
||||
*.snz
|
||||
*.spa
|
||||
*.spd
|
||||
*.spl
|
||||
*.spm
|
||||
*.spt
|
||||
*.sql
|
||||
*.sqf
|
||||
*.sqx
|
||||
*.sqz
|
||||
*.srep
|
||||
*.stg
|
||||
*.stkdoodlz
|
||||
*.stproj
|
||||
*.sy_
|
||||
*.tar.bz2
|
||||
*.tar.gz
|
||||
*.tar.gz2
|
||||
*.tar.lz
|
||||
*.tar.lzma
|
||||
*.tar.xz
|
||||
*.tar.z
|
||||
*.tar.zip
|
||||
*.taz
|
||||
*.tbz
|
||||
*.tbz2
|
||||
*.tcx
|
||||
*.text
|
||||
*.tg
|
||||
*.tgs
|
||||
*.tgz
|
||||
*.thumb
|
||||
*.tlz
|
||||
*.tlzma
|
||||
*.torrent
|
||||
*.tpsr
|
||||
*.trs
|
||||
*.txt
|
||||
*.tx_
|
||||
*.txz
|
||||
*.tz
|
||||
*.tzst
|
||||
*.ubz
|
||||
*.uc2
|
||||
*.ufdr
|
||||
*.ufs.uzip
|
||||
*.uha
|
||||
*.url
|
||||
*.uue
|
||||
*.uvm
|
||||
*.uzed
|
||||
*.uzip
|
||||
*.vbs
|
||||
*.vem
|
||||
*.vfs
|
||||
*.vib
|
||||
*.vip
|
||||
*.vmcz
|
||||
*.vms
|
||||
*.voca
|
||||
*.vpk
|
||||
*.vrpackage
|
||||
*.vsi
|
||||
*.vwi
|
||||
*.wa
|
||||
*.wacz
|
||||
*.waff
|
||||
*.war
|
||||
*.wastickers
|
||||
*.wdz
|
||||
*.whl
|
||||
*.wick
|
||||
*.wlb
|
||||
*.wot
|
||||
*.wsf
|
||||
*.wux
|
||||
*.xapk
|
||||
*.xar
|
||||
*.xcf.bz2
|
||||
*.xcf.gz
|
||||
*.xcf.xz
|
||||
*.xcfbz2
|
||||
*.xcfgz
|
||||
*.xcfxz
|
||||
*.xez
|
||||
*.xfp
|
||||
*.xip
|
||||
*.xml
|
||||
*.zipx
|
||||
*.xmcdz
|
||||
*.xoj
|
||||
*.xopp
|
||||
*.xx
|
||||
*.xz
|
||||
*.xzm
|
||||
*.y
|
||||
*.yc
|
||||
*.yz1
|
||||
*.z
|
||||
*.z00
|
||||
*.z01
|
||||
*.z02
|
||||
*.z03
|
||||
*.z04
|
||||
*.zabw
|
||||
*.zap
|
||||
*.zed
|
||||
*.zfsendtotarget
|
||||
*.zhelp
|
||||
*.zi
|
||||
*.zi_
|
||||
*.zim
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zix
|
||||
*.zl
|
||||
*.zoo
|
||||
*.zpaq
|
||||
*.zpi
|
||||
*.zsplit
|
||||
*.zst
|
||||
*.zw
|
||||
*.zwi
|
||||
*.zz
|
||||
@@ -1,115 +0,0 @@
|
||||
deployment:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
containers:
|
||||
- name: qbit
|
||||
image:
|
||||
repository: ghcr.io/flmorg/cleanuperr
|
||||
tag: latest
|
||||
env:
|
||||
- name: LOGGING__LOGLEVEL
|
||||
value: Information
|
||||
- name: LOGGING__FILE__ENABLED
|
||||
value: "true"
|
||||
- name: LOGGING__FILE__PATH
|
||||
value: /var/logs
|
||||
- name: LOGGING__ENHANCED
|
||||
value: "true"
|
||||
- name: TRIGGERS__QUEUECLEANER
|
||||
value: 0 0/5 * * * ?
|
||||
- name: TRIGGERS__CONTENTBLOCKER
|
||||
value: 0 0/5 * * * ?
|
||||
- name: QUEUECLEANER__ENABLED
|
||||
value: "true"
|
||||
- name: QUEUECLEANER__RUNSEQUENTIALLY
|
||||
value: "true"
|
||||
- name: QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES
|
||||
value: "3"
|
||||
- name: QUEUECLEANER__STALLED_MAX_STRIKES
|
||||
value: "3"
|
||||
- name: CONTENTBLOCKER__ENABLED
|
||||
value: "true"
|
||||
- name: CONTENTBLOCKER__BLACKLIST__ENABLED
|
||||
value: "true"
|
||||
- name: CONTENTBLOCKER__BLACKLIST__PATH
|
||||
value: https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
|
||||
- name: DOWNLOAD_CLIENT
|
||||
value: qbittorrent
|
||||
- name: QBITTORRENT__URL
|
||||
value: http://service.qbittorrent-videos.svc.cluster.local
|
||||
- name: SONARR__ENABLED
|
||||
value: "true"
|
||||
- name: SONARR__SEARCHTYPE
|
||||
value: Episode
|
||||
- name: SONARR__INSTANCES__0__URL
|
||||
value: http://service.sonarr-low-res.svc.cluster.local
|
||||
- name: SONARR__INSTANCES__1__URL
|
||||
value: http://service.sonarr-high-res.svc.cluster.local
|
||||
- name: RADARR__ENABLED
|
||||
value: "true"
|
||||
- name: RADARR__INSTANCES__0__URL
|
||||
value: http://service.radarr-low-res.svc.cluster.local
|
||||
- name: RADARR__INSTANCES__1__URL
|
||||
value: http://service.radarr-high-res.svc.cluster.local
|
||||
envFromSecret:
|
||||
- secretName: qbit-auth
|
||||
envs:
|
||||
- name: QBITTORRENT__USERNAME
|
||||
key: QBIT_USER
|
||||
- name: QBITTORRENT__PASSWORD
|
||||
key: QBIT_PASS
|
||||
- secretName: sonarr-auth
|
||||
envs:
|
||||
- name: SONARR__INSTANCES__0__APIKEY
|
||||
key: SNRL_API_KEY
|
||||
- name: SONARR__INSTANCES__1__APIKEY
|
||||
key: SNRH_API_KEY
|
||||
- secretName: radarr-auth
|
||||
envs:
|
||||
- name: RADARR__INSTANCES__0__APIKEY
|
||||
key: RDRL_API_KEY
|
||||
- name: RADARR__INSTANCES__1__APIKEY
|
||||
key: RDRH_API_KEY
|
||||
resources:
|
||||
requests:
|
||||
cpu: 0m
|
||||
memory: 0Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1000Mi
|
||||
volumeMounts:
|
||||
- name: storage
|
||||
mountPath: /var/logs
|
||||
subPath: cleanuperr/logs
|
||||
volumes:
|
||||
- name: storage
|
||||
type: pvc
|
||||
typeName: storage-pvc
|
||||
|
||||
pvcs:
|
||||
- name: storage-pvc
|
||||
storageClassName: local-path-persistent
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 1Gi
|
||||
volumeMode: Filesystem
|
||||
|
||||
vaultSecrets:
|
||||
- name: qbit-auth
|
||||
path: secrets/qbittorrent
|
||||
templates:
|
||||
QBIT_USER: "{% .Secrets.username %}"
|
||||
QBIT_PASS: "{% .Secrets.password %}"
|
||||
- name: radarr-auth
|
||||
path: secrets/radarr
|
||||
templates:
|
||||
RDRL_API_KEY: "{% .Secrets.low_api_key %}"
|
||||
RDRH_API_KEY: "{% .Secrets.high_api_key %}"
|
||||
- name: sonarr-auth
|
||||
path: secrets/sonarr
|
||||
templates:
|
||||
SNRL_API_KEY: "{% .Secrets.low_api_key %}"
|
||||
SNRH_API_KEY: "{% .Secrets.high_api_key %}"
|
||||
@@ -0,0 +1,87 @@
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node.js
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.angular/
|
||||
|
||||
# .NET
|
||||
backend/bin/
|
||||
backend/obj/
|
||||
backend/*/bin/
|
||||
backend/*/obj/
|
||||
backend/.vs/
|
||||
|
||||
# Build artifacts
|
||||
artifacts/
|
||||
dist/
|
||||
|
||||
# Test files
|
||||
backend/**/*Tests/
|
||||
backend/**/Tests/
|
||||
|
||||
# Development files
|
||||
docker-compose*.yml
|
||||
test/
|
||||
|
||||
# ================================
|
||||
# Node and build output
|
||||
# ================================
|
||||
node_modules
|
||||
dist
|
||||
out-tsc
|
||||
.angular
|
||||
.cache
|
||||
.tmp
|
||||
|
||||
# ================================
|
||||
# Testing & Coverage
|
||||
# ================================
|
||||
coverage
|
||||
jest
|
||||
cypress
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
reports
|
||||
playwright-report
|
||||
.vite
|
||||
.vitepress
|
||||
|
||||
# ================================
|
||||
# Environment & log files
|
||||
# ================================
|
||||
*.env*
|
||||
!*.env.production
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# ================================
|
||||
# Docker & local orchestration
|
||||
# ================================
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker-compose*.yml
|
||||
|
||||
# ================================
|
||||
# Miscellaneous
|
||||
# ================================
|
||||
*.bak
|
||||
*.old
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,19 +0,0 @@
|
||||
using Common.Configuration.ContentBlocker;
|
||||
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public abstract record ArrConfig
|
||||
{
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public Block Block { get; init; } = new();
|
||||
|
||||
public required List<ArrInstance> Instances { get; init; }
|
||||
}
|
||||
|
||||
public record Block
|
||||
{
|
||||
public BlocklistType Type { get; set; }
|
||||
|
||||
public string? Path { get; set; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed class ArrInstance
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
public required string ApiKey { get; set; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record LidarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Lidarr";
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record RadarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Radarr";
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record SonarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Sonarr";
|
||||
|
||||
public SonarrSearchType SearchType { get; init; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public enum SonarrSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
Series
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
|
||||
public sealed record ContentBlockerConfig : IJobConfig
|
||||
{
|
||||
public const string SectionName = "ContentBlocker";
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IGNORE_PRIVATE")]
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
[ConfigurationKeyName("DELETE_PRIVATE")]
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed record DelugeConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Deluge";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed record DownloadClientConfig
|
||||
{
|
||||
[ConfigurationKeyName("DOWNLOAD_CLIENT")]
|
||||
public Enums.DownloadClient DownloadClient { get; init; } = Enums.DownloadClient.None;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed class QBitConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "qBittorrent";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public record TransmissionConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Transmission";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.General;
|
||||
|
||||
public class HttpConfig : IConfig
|
||||
{
|
||||
[ConfigurationKeyName("HTTP_MAX_RETRIES")]
|
||||
public ushort MaxRetries { get; init; }
|
||||
|
||||
[ConfigurationKeyName("HTTP_TIMEOUT")]
|
||||
public ushort Timeout { get; init; } = 100;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Timeout is 0)
|
||||
{
|
||||
throw new ArgumentException("HTTP_TIMEOUT must be greater than 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Common.Configuration.General;
|
||||
|
||||
public sealed class TriggersConfig
|
||||
{
|
||||
public const string SectionName = "Triggers";
|
||||
|
||||
public required string QueueCleaner { get; init; }
|
||||
|
||||
public required string ContentBlocker { get; init; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public interface IJobConfig : IConfig
|
||||
{
|
||||
bool Enabled { get; init; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Common.Configuration.Logging;
|
||||
|
||||
public class FileLogConfig : IConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Common.Configuration.Logging;
|
||||
|
||||
public class LoggingConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Logging";
|
||||
|
||||
public LogEventLevel LogLevel { get; set; }
|
||||
|
||||
public bool Enhanced { get; set; }
|
||||
|
||||
public FileLogConfig? File { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.QueueCleaner;
|
||||
|
||||
public sealed record QueueCleanerConfig : IJobConfig
|
||||
{
|
||||
public const string SectionName = "QueueCleaner";
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public required bool RunSequentially { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
|
||||
public ushort ImportFailedMaxStrikes { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PRIVATE")]
|
||||
public bool ImportFailedIgnorePrivate { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_DELETE_PRIVATE")]
|
||||
public bool ImportFailedDeletePrivate { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PATTERNS")]
|
||||
public List<string>? ImportFailedIgnorePatterns { get; init; }
|
||||
|
||||
[ConfigurationKeyName("STALLED_MAX_STRIKES")]
|
||||
public ushort StalledMaxStrikes { get; init; }
|
||||
|
||||
[ConfigurationKeyName("STALLED_IGNORE_PRIVATE")]
|
||||
public bool StalledIgnorePrivate { get; init; }
|
||||
|
||||
[ConfigurationKeyName("STALLED_DELETE_PRIVATE")]
|
||||
public bool StalledDeletePrivate { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Common.Enums;
|
||||
|
||||
public enum DownloadClient
|
||||
{
|
||||
QBittorrent,
|
||||
Deluge,
|
||||
Transmission,
|
||||
None
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Common.Helpers;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public static readonly TimeSpan TriggerMaxLimit = TimeSpan.FromHours(6);
|
||||
public static readonly TimeSpan CacheLimitBuffer = TimeSpan.FromHours(2);
|
||||
|
||||
public const string HttpClientWithRetryName = "retry";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY frontend/package*.json ./
|
||||
# Use cache mount for npm to speed up builds
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci && npm install -g @angular/cli
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build with appropriate base-href and deploy-url
|
||||
RUN npm run build
|
||||
|
||||
# Build .NET backend
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.1
|
||||
ARG PACKAGES_USERNAME
|
||||
ARG PACKAGES_PAT
|
||||
WORKDIR /app
|
||||
EXPOSE 11011
|
||||
|
||||
# Copy source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Add NuGet source
|
||||
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
|
||||
# Restore and publish with cache mount
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
|
||||
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
|
||||
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
-a $TARGETARCH \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:Version=${VERSION} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
# Runtime stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install required packages for user management, timezone support, and Python for Apprise CLI
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
python3 \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create virtual environment and install Apprise CLI
|
||||
ENV VIRTUAL_ENV=/opt/apprise-venv
|
||||
RUN python3 -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
RUN pip install --no-cache-dir apprise==1.9.6
|
||||
|
||||
ENV PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
TZ=Etc/UTC \
|
||||
HTTP_PORTS=11011
|
||||
|
||||
# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546
|
||||
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend
|
||||
COPY --from=build /app/publish .
|
||||
# Copy frontend to wwwroot
|
||||
COPY --from=frontend-build /app/dist/ui/browser ./wwwroot
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["./Cleanuparr"]
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum InstanceType
|
||||
{
|
||||
Sonarr,
|
||||
Radarr,
|
||||
Lidarr,
|
||||
Readarr
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum StrikeType
|
||||
{
|
||||
Stalled,
|
||||
ImportFailed
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Domain.Models.Arr.Blocking;
|
||||
|
||||
public record BlockedItem
|
||||
{
|
||||
public required string Hash { get; init; }
|
||||
|
||||
public required Uri InstanceUrl { get; init; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Domain.Models.Arr.Blocking;
|
||||
|
||||
public sealed record LidarrBlockedItem : BlockedItem
|
||||
{
|
||||
public required long AlbumId { get; init; }
|
||||
|
||||
public required long ArtistId { get; init; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Models.Arr.Blocking;
|
||||
|
||||
public sealed record RadarrBlockedItem : BlockedItem
|
||||
{
|
||||
public required long MovieId { get; init; }
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Domain.Models.Arr.Blocking;
|
||||
|
||||
public sealed record SonarrBlockedItem : BlockedItem
|
||||
{
|
||||
public required long EpisodeId { get; init; }
|
||||
|
||||
public required long SeasonNumber { get; init; }
|
||||
|
||||
public required long SeriesId { get; init; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record TorrentStatus
|
||||
{
|
||||
public string? Hash { get; init; }
|
||||
|
||||
public string? State { get; init; }
|
||||
|
||||
public string? Name { get; init; }
|
||||
|
||||
public ulong Eta { get; init; }
|
||||
|
||||
public bool Private { get; init; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class ConfigurationDI
|
||||
{
|
||||
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
|
||||
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
|
||||
.Configure<DownloadClientConfig>(configuration)
|
||||
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
||||
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
|
||||
.Configure<TransmissionConfig>(configuration.GetSection(TransmissionConfig.SectionName))
|
||||
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
|
||||
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName))
|
||||
.Configure<LidarrConfig>(configuration.GetSection(LidarrConfig.SectionName))
|
||||
.Configure<LoggingConfig>(configuration.GetSection(LoggingConfig.SectionName));
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using Common.Configuration.Logging;
|
||||
using Domain.Enums;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration)
|
||||
{
|
||||
LoggingConfig? config = configuration.GetSection(LoggingConfig.SectionName).Get<LoggingConfig>();
|
||||
|
||||
if (!string.IsNullOrEmpty(config?.File?.Path) && !Directory.Exists(config.File.Path))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(config.File.Path);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"log file path is not a valid directory | {config.File.Path}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
|
||||
const string instanceNameTemplate = "{#if InstanceName is not null} {Concat('[',InstanceName,']'),ARR_PAD}{#end}";
|
||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m}}\n{{@x}}";
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m:lj}}\n{{@x}}";
|
||||
LogEventLevel level = LogEventLevel.Information;
|
||||
List<string> names = [nameof(ContentBlocker), nameof(QueueCleaner)];
|
||||
int jobPadding = names.Max(x => x.Length) + 2;
|
||||
names = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()];
|
||||
int arrPadding = names.Max(x => x.Length) + 2;
|
||||
|
||||
string consoleTemplate = consoleOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("ARR_PAD", arrPadding.ToString());
|
||||
string fileTemplate = fileOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("ARR_PAD", arrPadding.ToString());
|
||||
|
||||
if (config is not null)
|
||||
{
|
||||
level = config.LogLevel;
|
||||
|
||||
if (config.File?.Enabled is true)
|
||||
{
|
||||
logConfig.WriteTo.File(
|
||||
path: Path.Combine(config.File.Path, "cleanuperr-.txt"),
|
||||
formatter: new ExpressionTemplate(fileTemplate),
|
||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Logger = logConfig
|
||||
.MinimumLevel.Is(level)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.WriteTo.Console(new ExpressionTemplate(consoleTemplate))
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("ApplicationName", "cleanuperr")
|
||||
.CreateLogger();
|
||||
|
||||
return builder
|
||||
.ClearProviders()
|
||||
.AddSerilog();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Net;
|
||||
using Common.Configuration.General;
|
||||
using Common.Helpers;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients(configuration)
|
||||
.AddConfiguration(configuration)
|
||||
.AddMemoryCache()
|
||||
.AddServices()
|
||||
.AddQuartzServices(configuration);
|
||||
|
||||
private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// add default HttpClient
|
||||
services.AddHttpClient();
|
||||
|
||||
HttpConfig config = configuration.Get<HttpConfig>() ?? new();
|
||||
config.Validate();
|
||||
|
||||
// add retry HttpClient
|
||||
services
|
||||
.AddHttpClient(Constants.HttpClientWithRetryName, x =>
|
||||
{
|
||||
x.Timeout = TimeSpan.FromSeconds(config.Timeout);
|
||||
})
|
||||
.AddRetryPolicyHandler(config);
|
||||
|
||||
// add Deluge HttpClient
|
||||
services
|
||||
.AddHttpClient(nameof(DelugeService), x =>
|
||||
{
|
||||
x.Timeout = TimeSpan.FromSeconds(5);
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(_ =>
|
||||
{
|
||||
return new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer(),
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
})
|
||||
.AddRetryPolicyHandler(config);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IHttpClientBuilder AddRetryPolicyHandler(this IHttpClientBuilder builder, HttpConfig config) =>
|
||||
builder.AddPolicyHandler(
|
||||
HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
// do not retry on Unauthorized
|
||||
.OrResult(response => !response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Unauthorized)
|
||||
.WaitAndRetryAsync(config.MaxRetries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.General;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Quartz;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class QuartzDI
|
||||
{
|
||||
public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
TriggersConfig? config = configuration
|
||||
.GetRequiredSection(TriggersConfig.SectionName)
|
||||
.Get<TriggersConfig>();
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException("triggers configuration is null");
|
||||
}
|
||||
|
||||
q.AddJobs(configuration, config);
|
||||
})
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
|
||||
private static void AddJobs(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IConfiguration configuration,
|
||||
TriggersConfig triggersConfig
|
||||
)
|
||||
{
|
||||
ContentBlockerConfig? contentBlockerConfig = configuration
|
||||
.GetRequiredSection(ContentBlockerConfig.SectionName)
|
||||
.Get<ContentBlockerConfig>();
|
||||
|
||||
q.AddJob<ContentBlocker>(contentBlockerConfig, triggersConfig.ContentBlocker);
|
||||
|
||||
QueueCleanerConfig? queueCleanerConfig = configuration
|
||||
.GetRequiredSection(QueueCleanerConfig.SectionName)
|
||||
.Get<QueueCleanerConfig>();
|
||||
|
||||
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
|
||||
q.AddJobListener(new JobChainingListener(nameof(QueueCleaner)));
|
||||
}
|
||||
else
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, triggersConfig.QueueCleaner);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJob<T>(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IJobConfig? config,
|
||||
string trigger
|
||||
) where T: GenericHandler
|
||||
{
|
||||
string typeName = typeof(T).Name;
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException($"{typeName} configuration is null");
|
||||
}
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasTrigger = trigger.Length > 0;
|
||||
|
||||
q.AddJob<GenericJob<T>>(opts =>
|
||||
{
|
||||
opts.WithIdentity(typeName);
|
||||
|
||||
if (!hasTrigger)
|
||||
{
|
||||
// jobs with no triggers need to be stored durably
|
||||
opts.StoreDurably();
|
||||
}
|
||||
});
|
||||
|
||||
// skip empty triggers
|
||||
if (!hasTrigger)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ExampleTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(trigger)
|
||||
.Build();
|
||||
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
TimeSpan triggerValue = nextFireTimes[1] - nextFireTimes[0];
|
||||
|
||||
if (triggerValue > Constants.TriggerMaxLimit)
|
||||
{
|
||||
throw new Exception($"{trigger} should have a fire time of maximum 1 hour");
|
||||
}
|
||||
|
||||
if (triggerValue > StaticConfiguration.TriggerValue)
|
||||
{
|
||||
StaticConfiguration.TriggerValue = triggerValue;
|
||||
}
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-trigger")
|
||||
.WithCronSchedule(trigger, x =>x.WithMisfireHandlingInstructionDoNothing())
|
||||
.StartNow();
|
||||
});
|
||||
|
||||
// Startup trigger
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-startup-trigger")
|
||||
.StartNow();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<FilenameEvaluator>()
|
||||
.AddTransient<DummyDownloadService>()
|
||||
.AddTransient<QBitService>()
|
||||
.AddTransient<DelugeService>()
|
||||
.AddTransient<TransmissionService>()
|
||||
.AddTransient<ArrQueueIterator>()
|
||||
.AddTransient<DownloadServiceFactory>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<Striker>();
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>cleanuperr</AssemblyName>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,33 +0,0 @@
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Quartz;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class GenericJob<T> : IJob
|
||||
where T : GenericHandler
|
||||
{
|
||||
private readonly ILogger<GenericJob<T>> _logger;
|
||||
private readonly T _handler;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
|
||||
|
||||
try
|
||||
{
|
||||
await _handler.ExecuteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{name} failed", typeof(T).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Executable.DependencyInjection;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Logging.AddLogging(builder.Configuration);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
var logger = host.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
|
||||
logger.LogInformation(
|
||||
version is null
|
||||
? "cleanuperr version not detected"
|
||||
: $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}"
|
||||
);
|
||||
|
||||
host.Run();
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"HTTP_MAX_RETRIES": 0,
|
||||
"HTTP_TIMEOUT": 10,
|
||||
"Logging": {
|
||||
"LogLevel": "Debug",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0/10 * * * * ?",
|
||||
"ContentBlocker": "0/10 * * * * ?"
|
||||
},
|
||||
"ContentBlocker": {
|
||||
"Enabled": true,
|
||||
"IGNORE_PRIVATE": true,
|
||||
"DELETE_PRIVATE": false
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"IMPORT_FAILED_IGNORE_PRIVATE": true,
|
||||
"IMPORT_FAILED_DELETE_PRIVATE": false,
|
||||
"IMPORT_FAILED_IGNORE_PATTERNS": [
|
||||
"file is a sample"
|
||||
],
|
||||
"STALLED_MAX_STRIKES": 5,
|
||||
"STALLED_IGNORE_PRIVATE": true,
|
||||
"STALLED_DELETE_PRIVATE": false
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"SearchType": "Episode",
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": "425d1e713f0c405cbbf359ac0502c1f4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": true,
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": "8b7454f668e54c5b8f44f56f93969761"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Lidarr": {
|
||||
"Enabled": true,
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8686",
|
||||
"ApiKey": "7f677cfdc074414397af53dd633860c5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"HTTP_MAX_RETRIES": 0,
|
||||
"HTTP_TIMEOUT": 100,
|
||||
"Logging": {
|
||||
"LogLevel": "Information",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0 0/5 * * * ?",
|
||||
"ContentBlocker": "0 0/5 * * * ?"
|
||||
},
|
||||
"ContentBlocker": {
|
||||
"Enabled": false,
|
||||
"IGNORE_PRIVATE": false
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"IMPORT_FAILED_IGNORE_PRIVATE": false,
|
||||
"IMPORT_FAILED_DELETE_PRIVATE": false,
|
||||
"IMPORT_FAILED_IGNORE_PATTERNS": [],
|
||||
"STALLED_MAX_STRIKES": 5,
|
||||
"STALLED_IGNORE_PRIVATE": false,
|
||||
"STALLED_DELETE_PRIVATE": false
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "none",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": false,
|
||||
"SearchType": "Episode",
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": ""
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": false,
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": ""
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Lidarr": {
|
||||
"Enabled": false,
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": ""
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8686",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.0" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,187 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Arr;
|
||||
|
||||
public abstract class ArrClient
|
||||
{
|
||||
protected readonly ILogger<ArrClient> _logger;
|
||||
protected readonly HttpClient _httpClient;
|
||||
protected readonly LoggingConfig _loggingConfig;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly Striker _striker;
|
||||
|
||||
protected ArrClient(
|
||||
ILogger<ArrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<LoggingConfig> loggingConfig,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
Striker striker
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_striker = striker;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
_loggingConfig = loggingConfig.Value;
|
||||
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page));
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue list failed | {uri}", uri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public virtual bool ShouldRemoveFromQueue(QueueRecord record, bool isPrivateDownload)
|
||||
{
|
||||
if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip failed import check | download is private | {name}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasWarn() => record.TrackedDownloadStatus
|
||||
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportBlocked() => record.TrackedDownloadState
|
||||
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportPending() => record.TrackedDownloadState
|
||||
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
if (hasWarn() && (isImportBlocked() || isImportPending()))
|
||||
{
|
||||
if (HasIgnoredPatterns(record))
|
||||
{
|
||||
_logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _striker.StrikeAndCheckLimit(
|
||||
record.DownloadId,
|
||||
record.Title,
|
||||
_queueCleanerConfig.ImportFailedMaxStrikes,
|
||||
StrikeType.ImportFailed
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, GetQueueDeleteUrlPath(record.Id, removeFromClient));
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Delete, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation(
|
||||
removeFromClient
|
||||
? "queue item deleted | {url} | {title}"
|
||||
: "queue item removed from arr | {url} | {title}",
|
||||
arrInstance.Url,
|
||||
record.Title
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue delete failed | {uri} | {title}", uri, record.Title);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
public virtual bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.DownloadId.Equals(record.Title, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("skip | item is not ready yet | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected abstract string GetQueueUrlPath(int page);
|
||||
|
||||
protected abstract string GetQueueDeleteUrlPath(long recordId, bool removeFromClient);
|
||||
|
||||
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
|
||||
{
|
||||
request.Headers.Add("x-api-key", apiKey);
|
||||
}
|
||||
|
||||
private bool HasIgnoredPatterns(QueueRecord record)
|
||||
{
|
||||
if (_queueCleanerConfig.ImportFailedIgnorePatterns?.Count is null or 0)
|
||||
{
|
||||
// no patterns are configured
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.StatusMessages?.Count is null or 0)
|
||||
{
|
||||
// no status message found
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<string> messages = record.StatusMessages
|
||||
.SelectMany(x => x.Messages ?? Enumerable.Empty<string>())
|
||||
.ToHashSet();
|
||||
record.StatusMessages.Select(x => x.Title)
|
||||
.ToList()
|
||||
.ForEach(x => messages.Add(x));
|
||||
|
||||
return messages.Any(
|
||||
m => _queueCleanerConfig.ImportFailedIgnorePatterns.Any(
|
||||
p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Helpers;
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly SonarrConfig _sonarrConfig;
|
||||
private readonly RadarrConfig _radarrConfig;
|
||||
private readonly LidarrConfig _lidarrConfig;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private bool _initialized;
|
||||
|
||||
private const string Type = "type";
|
||||
private const string Patterns = "patterns";
|
||||
private const string Regexes = "regexes";
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
IOptions<LidarrConfig> lidarrConfig,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_sonarrConfig = sonarrConfig.Value;
|
||||
_radarrConfig = radarrConfig.Value;
|
||||
_lidarrConfig = lidarrConfig.Value;
|
||||
_cache = cache;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistsAsync()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
_logger.LogDebug("blocklists already loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await LoadPatternsAndRegexesAsync(_sonarrConfig.Block.Type, _sonarrConfig.Block.Path, InstanceType.Sonarr);
|
||||
await LoadPatternsAndRegexesAsync(_radarrConfig.Block.Type, _radarrConfig.Block.Path, InstanceType.Radarr);
|
||||
await LoadPatternsAndRegexesAsync(_lidarrConfig.Block.Type, _lidarrConfig.Block.Path, InstanceType.Lidarr);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("failed to load blocklists");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public BlocklistType GetBlocklistType(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue($"{instanceType.ToString()}_{Type}", out BlocklistType? blocklistType);
|
||||
|
||||
return blocklistType ?? BlocklistType.Blacklist;
|
||||
}
|
||||
|
||||
public ConcurrentBag<string> GetPatterns(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue($"{instanceType.ToString()}_{Patterns}", out ConcurrentBag<string>? patterns);
|
||||
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
public ConcurrentBag<Regex> GetRegexes(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue($"{instanceType.ToString()}_{Regexes}", out ConcurrentBag<Regex>? regexes);
|
||||
|
||||
return regexes ?? [];
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistType blocklistType, string? blocklistPath, InstanceType instanceType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blocklistPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(blocklistPath);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
const string regexId = "regex:";
|
||||
ConcurrentBag<string> patterns = [];
|
||||
ConcurrentBag<Regex> regexes = [];
|
||||
|
||||
Parallel.ForEach(filePatterns, options, pattern =>
|
||||
{
|
||||
if (!pattern.StartsWith(regexId))
|
||||
{
|
||||
patterns.Add(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
pattern = pattern[regexId.Length..];
|
||||
|
||||
try
|
||||
{
|
||||
Regex regex = new(pattern, RegexOptions.Compiled);
|
||||
regexes.Add(regex);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
_logger.LogWarning("invalid regex | {pattern}", pattern);
|
||||
}
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_cache.Set($"{instanceType.ToString()}_{Type}", blocklistType);
|
||||
_cache.Set($"{instanceType.ToString()}_{Patterns}", patterns);
|
||||
_cache.Set($"{instanceType.ToString()}_{Regexes}", regexes);
|
||||
|
||||
_logger.LogDebug("loaded {count} patterns", patterns.Count);
|
||||
_logger.LogDebug("loaded {count} regexes", regexes.Count);
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistPath);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
// http(s) url
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"blocklist not found | {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class ContentBlocker : GenericHandler
|
||||
{
|
||||
private readonly ContentBlockerConfig _config;
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public ContentBlocker(
|
||||
ILogger<ContentBlocker> logger,
|
||||
IOptions<ContentBlockerConfig> config,
|
||||
IOptions<DownloadClientConfig> downloadClientConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
IOptions<LidarrConfig> lidarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(
|
||||
logger, downloadClientConfig,
|
||||
sonarrConfig, radarrConfig, lidarrConfig,
|
||||
sonarrClient, radarrClient, lidarrClient,
|
||||
arrArrQueueIterator, downloadServiceFactory
|
||||
)
|
||||
{
|
||||
_config = config.Value;
|
||||
_blocklistProvider = blocklistProvider;
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None)
|
||||
{
|
||||
_logger.LogWarning("download client is set to none");
|
||||
return;
|
||||
}
|
||||
|
||||
bool blocklistIsConfigured = _sonarrConfig.Enabled && !string.IsNullOrEmpty(_sonarrConfig.Block.Path) ||
|
||||
_radarrConfig.Enabled && !string.IsNullOrEmpty(_radarrConfig.Block.Path) ||
|
||||
_lidarrConfig.Enabled && !string.IsNullOrEmpty(_lidarrConfig.Block.Path);
|
||||
|
||||
if (!blocklistIsConfigured)
|
||||
{
|
||||
_logger.LogWarning("no blocklist is configured");
|
||||
return;
|
||||
}
|
||||
|
||||
await _blocklistProvider.LoadBlocklistsAsync();
|
||||
await base.ExecuteAsync();
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());
|
||||
|
||||
HashSet<SearchItem> itemsToBeRefreshed = [];
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
QueueRecord record = group.First();
|
||||
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
|
||||
BlockFilesResult result = await _downloadService
|
||||
.BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes);
|
||||
|
||||
if (!result.ShouldRemove)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("all files are marked as unwanted | {hash}", record.Title);
|
||||
|
||||
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
|
||||
|
||||
bool removeFromClient = true;
|
||||
|
||||
if (result.IsPrivate && !_config.DeletePrivate)
|
||||
{
|
||||
removeFromClient = false;
|
||||
}
|
||||
|
||||
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient);
|
||||
}
|
||||
});
|
||||
|
||||
await arrClient.RefreshItemsAsync(instance, itemsToBeRefreshed);
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Models.Deluge.Response;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public sealed class DelugeService : DownloadServiceBase
|
||||
{
|
||||
private readonly DelugeClient _client;
|
||||
|
||||
public DelugeService(
|
||||
ILogger<DelugeService> logger,
|
||||
IOptions<DelugeConfig> config,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
IOptions<ContentBlockerConfig> contentBlockerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, contentBlockerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
config.Value.Validate();
|
||||
_client = new (config, httpClientFactory);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.LoginAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
DelugeContents? contents = null;
|
||||
StalledResult result = new();
|
||||
|
||||
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||
|
||||
if (status?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
bool shouldRemove = contents?.Contents?.Count > 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (_, file) =>
|
||||
{
|
||||
if (file.Priority > 0)
|
||||
{
|
||||
shouldRemove = false;
|
||||
}
|
||||
});
|
||||
|
||||
result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(status);
|
||||
result.IsPrivate = status.Private;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
||||
string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes
|
||||
)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (status?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = status.Private;
|
||||
|
||||
if (_contentBlockerConfig.IgnorePrivate && status.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", status.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
DelugeContents? contents = null;
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
if (contents is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
Dictionary<int, int> priorities = [];
|
||||
bool hasPriorityUpdates = false;
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
totalFiles++;
|
||||
int priority = file.Priority;
|
||||
|
||||
if (file.Priority is 0)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes))
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
priority = 0;
|
||||
hasPriorityUpdates = true;
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
}
|
||||
|
||||
priorities.Add(file.Index, priority);
|
||||
});
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
List<int> sortedPriorities = priorities
|
||||
.OrderBy(x => x.Key)
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
result.ShouldRemove = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task Delete(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
await _client.DeleteTorrent(hash);
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentStatus status)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && status.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(status.Hash!, status.Name!);
|
||||
}
|
||||
|
||||
private async Task<TorrentStatus?> GetTorrentStatus(string hash)
|
||||
{
|
||||
return await _client.SendRequest<TorrentStatus?>(
|
||||
"web.get_torrent_status",
|
||||
hash,
|
||||
new[] { "hash", "state", "name", "eta", "private" }
|
||||
);
|
||||
}
|
||||
|
||||
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile)
|
||||
{
|
||||
if (contents is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (name, data) in contents)
|
||||
{
|
||||
switch (data.Type)
|
||||
{
|
||||
case "file":
|
||||
processFile(name, data);
|
||||
break;
|
||||
case "dir" when data.Contents is not null:
|
||||
// Recurse into subdirectories
|
||||
ProcessFiles(data.Contents, processFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public abstract class DownloadServiceBase : IDownloadService
|
||||
{
|
||||
protected readonly ILogger<DownloadServiceBase> _logger;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly ContentBlockerConfig _contentBlockerConfig;
|
||||
protected readonly FilenameEvaluator _filenameEvaluator;
|
||||
protected readonly Striker _striker;
|
||||
|
||||
protected DownloadServiceBase(
|
||||
ILogger<DownloadServiceBase> logger,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
IOptions<ContentBlockerConfig> contentBlockerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||
_contentBlockerConfig = contentBlockerConfig.Value;
|
||||
_filenameEvaluator = filenameEvaluator;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public abstract void Dispose();
|
||||
|
||||
public abstract Task LoginAsync();
|
||||
|
||||
public abstract Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
||||
string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes
|
||||
);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task Delete(string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Strikes an item and checks if the limit has been reached.
|
||||
/// </summary>
|
||||
/// <param name="hash">The torrent hash.</param>
|
||||
/// <param name="itemName">The name or title of the item.</param>
|
||||
/// <returns>True if the limit has been reached; otherwise, false.</returns>
|
||||
protected bool StrikeAndCheckLimit(string hash, string itemName)
|
||||
{
|
||||
return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public sealed class DownloadServiceFactory
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly Common.Enums.DownloadClient _downloadClient;
|
||||
|
||||
public DownloadServiceFactory(IServiceProvider serviceProvider, IOptions<DownloadClientConfig> downloadClientConfig)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_downloadClient = downloadClientConfig.Value.DownloadClient;
|
||||
}
|
||||
|
||||
public IDownloadService CreateDownloadClient() =>
|
||||
_downloadClient switch
|
||||
{
|
||||
Common.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService<QBitService>(),
|
||||
Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(),
|
||||
Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(),
|
||||
Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService<DummyDownloadService>(),
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public sealed class DummyDownloadService : DownloadServiceBase
|
||||
{
|
||||
public DummyDownloadService(ILogger<DownloadServiceBase> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig, FilenameEvaluator filenameEvaluator, Striker striker) : base(logger, queueCleanerConfig, contentBlockerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public override Task LoginAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task Delete(string hash)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public interface IDownloadService : IDisposable
|
||||
{
|
||||
public Task LoginAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the download should be removed from the *arr queue.
|
||||
/// </summary>
|
||||
/// <param name="hash">The download hash.</param>
|
||||
public Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Blocks unwanted files from being fully downloaded.
|
||||
/// </summary>
|
||||
/// <param name="hash">The torrent hash.</param>
|
||||
/// <param name="blocklistType">The <see cref="BlocklistType"/>.</param>
|
||||
/// <param name="patterns">The patterns to test the files against.</param>
|
||||
/// <param name="regexes">The regexes to test the files against.</param>
|
||||
/// <returns>True if all files have been blocked; otherwise false.</returns>
|
||||
public Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
||||
string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a download item.
|
||||
/// </summary>
|
||||
public Task Delete(string hash);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
public sealed class QBitService : DownloadServiceBase
|
||||
{
|
||||
private readonly QBitConfig _config;
|
||||
private readonly QBittorrentClient _client;
|
||||
|
||||
public QBitService(
|
||||
ILogger<QBitService> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<QBitConfig> config,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
IOptions<ContentBlockerConfig> contentBlockerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, contentBlockerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
_config = config.Value;
|
||||
_config.Validate();
|
||||
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), _config.Url);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.LoginAsync(_config.Username, _config.Password);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
StalledResult result = new();
|
||||
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
// if all files were blocked by qBittorrent
|
||||
if (torrent is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
result.ShouldRemove = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
// if all files are marked as skip
|
||||
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||
{
|
||||
result.ShouldRemove = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ShouldRemove = IsItemStuckAndShouldRemove(torrent, result.IsPrivate);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
||||
string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes
|
||||
)
|
||||
{
|
||||
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
if (_contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", torrent.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
List<int> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Name);
|
||||
unwantedFiles.Add(file.Index.Value);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
result.ShouldRemove = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (int fileIndex in unwantedFiles)
|
||||
{
|
||||
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task Delete(string hash)
|
||||
{
|
||||
await _client.DeleteAsync(hash, deleteDownloadedData: true);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(torrent.Hash, torrent.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public sealed record StalledResult
|
||||
{
|
||||
/// <summary>
|
||||
/// True if the download should be removed; otherwise false.
|
||||
/// </summary>
|
||||
public bool ShouldRemove { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the download is private; otherwise false.
|
||||
/// </summary>
|
||||
public bool IsPrivate { get; set; }
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Transmission.API.RPC;
|
||||
using Transmission.API.RPC.Arguments;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
|
||||
public sealed class TransmissionService : DownloadServiceBase
|
||||
{
|
||||
private readonly TransmissionConfig _config;
|
||||
private readonly Client _client;
|
||||
private TorrentInfo[]? _torrentsCache;
|
||||
|
||||
public TransmissionService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<TransmissionService> logger,
|
||||
IOptions<TransmissionConfig> config,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
IOptions<ContentBlockerConfig> contentBlockerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, contentBlockerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
_config = config.Value;
|
||||
_config.Validate();
|
||||
_client = new(
|
||||
httpClientFactory.CreateClient(Constants.HttpClientWithRetryName),
|
||||
new Uri(_config.Url, "/transmission/rpc").ToString(),
|
||||
login: _config.Username,
|
||||
password: _config.Password
|
||||
);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.GetSessionInformationAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
StalledResult result = new();
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool shouldRemove = torrent.FileStats?.Length > 0;
|
||||
result.IsPrivate = torrent.IsPrivate ?? false;
|
||||
|
||||
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
|
||||
{
|
||||
if (!stats.Wanted.HasValue)
|
||||
{
|
||||
// if any files stats are missing, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
|
||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||
{
|
||||
// if any files are wanted, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
}
|
||||
|
||||
// remove if all files are unwanted or download is stuck
|
||||
result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(torrent);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
||||
string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes
|
||||
)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (torrent?.FileStats is null || torrent.Files is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isPrivate = torrent.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
if (_contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", torrent.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<long> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
for (int i = 0; i < torrent.Files.Length; i++)
|
||||
{
|
||||
if (torrent.FileStats?[i].Wanted == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (!torrent.FileStats[i].Wanted.Value)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(torrent.Files[i].Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", torrent.Files[i].Name);
|
||||
unwantedFiles.Add(i);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
result.ShouldRemove = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
await _client.TorrentSetAsync(new TorrentSettings
|
||||
{
|
||||
Ids = [ torrent.Id ],
|
||||
FilesUnwanted = unwantedFiles.ToArray(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task Delete(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.TorrentRemoveAsync([torrent.Id], true);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && (torrent.IsPrivate ?? false))
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (torrent.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return false;
|
||||
}
|
||||
|
||||
if (torrent.Eta > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!);
|
||||
}
|
||||
|
||||
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = _torrentsCache?
|
||||
.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (_torrentsCache is null || torrent is null)
|
||||
{
|
||||
string[] fields = [
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS,
|
||||
TorrentFields.IS_PRIVATE
|
||||
];
|
||||
|
||||
// refresh cache
|
||||
_torrentsCache = (await _client.TorrentGetAsync(fields))
|
||||
?.Torrents;
|
||||
}
|
||||
|
||||
if (_torrentsCache?.Length is null or 0)
|
||||
{
|
||||
_logger.LogDebug("could not list torrents | {url}", _config.Url);
|
||||
}
|
||||
|
||||
torrent = _torrentsCache?.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("could not find torrent | {hash} | {url}", hash, _config.Url);
|
||||
}
|
||||
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using Common.Helpers;
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.ItemStriker;
|
||||
|
||||
public class Striker
|
||||
{
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
|
||||
public Striker(ILogger<Striker> logger, IMemoryCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||
}
|
||||
|
||||
public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
|
||||
{
|
||||
if (maxStrikes is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string key = $"{strikeType.ToString()}_{hash}";
|
||||
|
||||
if (!_cache.TryGetValue(key, out int? strikeCount))
|
||||
{
|
||||
strikeCount = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
++strikeCount;
|
||||
}
|
||||
|
||||
_logger.LogInformation("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
|
||||
_cache.Set(key, strikeCount, _cacheOptions);
|
||||
|
||||
if (strikeCount < maxStrikes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strikeCount > maxStrikes)
|
||||
{
|
||||
_logger.LogWarning("blocked item keeps coming back | {name}", itemName);
|
||||
_logger.LogWarning("be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items");
|
||||
}
|
||||
|
||||
_logger.LogInformation("removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.Jobs;
|
||||
|
||||
public abstract class GenericHandler : IDisposable
|
||||
{
|
||||
protected readonly ILogger<GenericHandler> _logger;
|
||||
protected readonly DownloadClientConfig _downloadClientConfig;
|
||||
protected readonly SonarrConfig _sonarrConfig;
|
||||
protected readonly RadarrConfig _radarrConfig;
|
||||
protected readonly LidarrConfig _lidarrConfig;
|
||||
protected readonly SonarrClient _sonarrClient;
|
||||
protected readonly RadarrClient _radarrClient;
|
||||
protected readonly LidarrClient _lidarrClient;
|
||||
protected readonly ArrQueueIterator _arrArrQueueIterator;
|
||||
protected readonly IDownloadService _downloadService;
|
||||
|
||||
protected GenericHandler(
|
||||
ILogger<GenericHandler> logger,
|
||||
IOptions<DownloadClientConfig> downloadClientConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
IOptions<LidarrConfig> lidarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_downloadClientConfig = downloadClientConfig.Value;
|
||||
_sonarrConfig = sonarrConfig.Value;
|
||||
_radarrConfig = radarrConfig.Value;
|
||||
_lidarrConfig = lidarrConfig.Value;
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
_lidarrClient = lidarrClient;
|
||||
_arrArrQueueIterator = arrArrQueueIterator;
|
||||
_downloadService = downloadServiceFactory.CreateDownloadClient();
|
||||
}
|
||||
|
||||
public virtual async Task ExecuteAsync()
|
||||
{
|
||||
await _downloadService.LoginAsync();
|
||||
|
||||
await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr);
|
||||
await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr);
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_downloadService.Dispose();
|
||||
}
|
||||
|
||||
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
|
||||
|
||||
private async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ArrInstance arrInstance in config.Instances)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessInstanceAsync(arrInstance, instanceType);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected ArrClient GetClient(InstanceType type) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => _sonarrClient,
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
InstanceType.Lidarr => _lidarrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
|
||||
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && !isPack => new SonarrSearchItem
|
||||
{
|
||||
Id = record.EpisodeId,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Episode
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && isPack => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Season
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Series
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeriesId
|
||||
},
|
||||
InstanceType.Radarr => new SearchItem
|
||||
{
|
||||
Id = record.MovieId
|
||||
},
|
||||
InstanceType.Lidarr => new SearchItem
|
||||
{
|
||||
Id = record.AlbumId
|
||||
},
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Quartz;
|
||||
|
||||
namespace Infrastructure.Verticals.Jobs;
|
||||
|
||||
public class JobChainingListener : IJobListener
|
||||
{
|
||||
private readonly string _nextJobName;
|
||||
|
||||
public JobChainingListener(string nextJobName)
|
||||
{
|
||||
_nextJobName = nextJobName;
|
||||
}
|
||||
|
||||
public string Name => nameof(JobChainingListener);
|
||||
|
||||
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IScheduler scheduler = context.Scheduler;
|
||||
JobKey nextJobKey = new(_nextJobName);
|
||||
|
||||
if (await scheduler.CheckExists(nextJobKey, cancellationToken))
|
||||
{
|
||||
await scheduler.TriggerJob(nextJobKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
public sealed class QueueCleaner : GenericHandler
|
||||
{
|
||||
private readonly QueueCleanerConfig _config;
|
||||
|
||||
public QueueCleaner(
|
||||
ILogger<QueueCleaner> logger,
|
||||
IOptions<QueueCleanerConfig> config,
|
||||
IOptions<DownloadClientConfig> downloadClientConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
IOptions<LidarrConfig> lidarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
LidarrClient lidarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(
|
||||
logger, downloadClientConfig,
|
||||
sonarrConfig, radarrConfig, lidarrConfig,
|
||||
sonarrClient, radarrClient, lidarrClient,
|
||||
arrArrQueueIterator, downloadServiceFactory
|
||||
)
|
||||
{
|
||||
_config = config.Value;
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());
|
||||
|
||||
HashSet<SearchItem> itemsToBeRefreshed = [];
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueRecord record = group.First();
|
||||
|
||||
if (!arrClient.IsRecordValid(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
StalledResult stalledCheckResult = new();
|
||||
|
||||
if (_downloadClientConfig.DownloadClient is not Common.Enums.DownloadClient.None && record.Protocol is "torrent")
|
||||
{
|
||||
// stalled download check
|
||||
stalledCheckResult = await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId);
|
||||
}
|
||||
|
||||
// failed import check
|
||||
bool shouldRemoveFromArr = arrClient.ShouldRemoveFromQueue(record, stalledCheckResult.IsPrivate);
|
||||
|
||||
if (!shouldRemoveFromArr && !stalledCheckResult.ShouldRemove)
|
||||
{
|
||||
_logger.LogInformation("skip | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
|
||||
|
||||
bool removeFromClient = true;
|
||||
|
||||
if (stalledCheckResult.IsPrivate)
|
||||
{
|
||||
if (stalledCheckResult.ShouldRemove && !_config.StalledDeletePrivate)
|
||||
{
|
||||
removeFromClient = false;
|
||||
}
|
||||
|
||||
if (shouldRemoveFromArr && !_config.ImportFailedDeletePrivate)
|
||||
{
|
||||
removeFromClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient);
|
||||
}
|
||||
});
|
||||
|
||||
await arrClient.RefreshItemsAsync(instance, itemsToBeRefreshed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.DEFAULT_GOAL := no-default
|
||||
|
||||
no-default:
|
||||
$(error You must specify a make target)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef version
|
||||
$(error version is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef user
|
||||
$(error user is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef pat
|
||||
$(error pat is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VERSION=$(version) \
|
||||
--build-arg PACKAGES_USERNAME=$(user) \
|
||||
--build-arg PACKAGES_PAT=$(pat) \
|
||||
-t cleanuparr:$(tag) \
|
||||
.
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
// Integration tests share file-system state (config-dir users.db used by SetupGuardMiddleware),
|
||||
// so we must run them sequentially to avoid interference between factories.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
|
||||
/// The database file is created in a temp directory so both DI and static contexts share the same data.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove the existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Also remove the DbContext registration itself
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
|
||||
// Ensure DB is created using a minimal isolated context (not the full app DI container)
|
||||
// to avoid any residual static state contamination.
|
||||
using var db = new UsersContext(
|
||||
new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options);
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
|
||||
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
|
||||
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcLinkWebApplicationFactory _factory;
|
||||
|
||||
// Shared across ordered tests
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Login_StoreAccessToken()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
|
||||
|
||||
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
|
||||
body.TryGetProperty("requiresTwoFactor", out var rtf)
|
||||
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
|
||||
rtf.GetBoolean().ShouldBeFalse();
|
||||
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
var statusResponse = await _client.GetAsync("/api/auth/status");
|
||||
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
|
||||
{
|
||||
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
|
||||
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
|
||||
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
|
||||
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
|
||||
{
|
||||
// First trigger StartOidcLink so the mock captures the initiatorUserId
|
||||
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Now simulate the IdP callback with the mock's success state
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
|
||||
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link=success");
|
||||
location.ShouldNotContain("oidc_link_error");
|
||||
|
||||
// Verify the subject was saved to config
|
||||
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
|
||||
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Create a fresh unauthenticated client
|
||||
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true,
|
||||
issuerUrl = "https://mock-oidc-provider.test",
|
||||
clientId = "test-client",
|
||||
clientSecret = "test-secret",
|
||||
scopes = "openid profile email",
|
||||
authorizedSubject = MockOidcAuthService.LinkedSubject,
|
||||
providerName = "TestProvider",
|
||||
redirectUrl = "",
|
||||
exclusiveMode = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Verify exclusive mode was reset
|
||||
var exclusiveMode = await _factory.GetExclusiveModeAsync();
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
await _factory.EnableOidcAsync();
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword789!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
public class OidcLinkWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _configDir;
|
||||
|
||||
public OidcLinkWebApplicationFactory()
|
||||
{
|
||||
_configDir = Cleanuparr.Shared.Helpers.ConfigurationPathProvider.GetConfigPath();
|
||||
|
||||
// Delete both databases (and their WAL sidecar files) so each test run starts clean.
|
||||
// users.db must be at the config path so CreateStaticInstance() (used by
|
||||
// SetupGuardMiddleware) reads the same file as the DI-injected UsersContext.
|
||||
// ClearAllPools() releases any SQLite connections held by the previous test factory's
|
||||
// connection pool, which would otherwise prevent file deletion on Windows or cause
|
||||
// SQLite to reconstruct a partial database from stale WAL files on other platforms.
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (var name in new[] {
|
||||
"users.db", "users.db-shm", "users.db-wal",
|
||||
"cleanuparr.db", "cleanuparr.db-shm", "cleanuparr.db-wal" })
|
||||
{
|
||||
var path = Path.Combine(_configDir, name);
|
||||
if (File.Exists(path))
|
||||
try { File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
// Use the config-path db so SetupGuardMiddleware (CreateStaticInstance) sees
|
||||
// the same data as the DI-injected context. Apply the same naming conventions
|
||||
// as CreateStaticInstance() so the schemas match.
|
||||
var dbPath = Path.Combine(_configDir, "users.db");
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention();
|
||||
});
|
||||
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
|
||||
// Ensure DB is created using a minimal isolated context (not the full app DI container)
|
||||
// to avoid any residual static state contamination.
|
||||
using var db = new UsersContext(
|
||||
new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options);
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "initial-subject",
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAuthorizedSubjectAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.AuthorizedSubject;
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> GetExclusiveModeAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.ExclusiveMode ?? false;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (var name in new[] {
|
||||
"users.db", "users.db-shm", "users.db-wal",
|
||||
"cleanuparr.db", "cleanuparr.db-shm", "cleanuparr.db-wal" })
|
||||
{
|
||||
var path = Path.Combine(_configDir, name);
|
||||
if (File.Exists(path))
|
||||
try { File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string LinkSuccessState = "mock-link-success-state";
|
||||
public const string NoInitiatorState = "mock-no-initiator-state";
|
||||
public const string LinkedSubject = "newly-linked-subject-123";
|
||||
|
||||
private string? _lastInitiatorUserId;
|
||||
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
_lastInitiatorUserId = initiatorUserId;
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
|
||||
State = LinkSuccessState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == LinkSuccessState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
PreferredUsername = "linkuser",
|
||||
Email = "link@example.com",
|
||||
InitiatorUserId = _lastInitiatorUserId
|
||||
});
|
||||
}
|
||||
|
||||
if (state == NoInitiatorState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
InitiatorUserId = null // No initiator — controller should redirect with error
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
|
||||
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the authentication flow.
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.Headers.CacheControl.ShouldNotBeNull();
|
||||
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
|
||||
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "another",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||
|
||||
// Store the secret for the next test
|
||||
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||
{
|
||||
// If we don't have the secret from the previous test, generate it again
|
||||
if (string.IsNullOrEmpty(_totpSecret))
|
||||
{
|
||||
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
var code = GenerateTotpCode(_totpSecret);
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Setup_Complete_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||
{
|
||||
// Make multiple failed attempts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
|
||||
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task Setup_Complete_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Login endpoint must NOT be blocked by the middleware (403).
|
||||
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "nonexistent-one-time-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Verify OIDC fields exist in the response (values depend on shared static DB state)
|
||||
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC authentication flow.
|
||||
/// Uses a mock IOidcAuthService to simulate IdP behavior.
|
||||
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // We want to inspect redirects
|
||||
});
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
|
||||
// but the controller returns BadRequest because OIDC is not configured
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
// Create account
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Complete setup (skip 2FA for this test suite)
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "some-random-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/login");
|
||||
location.ShouldContain("oidc_error=provider_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
|
||||
{
|
||||
// Even with code and state, if the state is invalid the mock will return failure
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
// Verify auth status reflects OIDC enabled
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
|
||||
{
|
||||
// Use the mock's valid state to simulate a successful callback
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/oidc/callback");
|
||||
location.ShouldContain("code=");
|
||||
// Should NOT contain oidc_error
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
|
||||
{
|
||||
// First, trigger a valid callback to get a one-time code
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
|
||||
// Extract the one-time code from the redirect URL
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"];
|
||||
oneTimeCode.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Exchange the one-time code for tokens
|
||||
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = oneTimeCode
|
||||
});
|
||||
|
||||
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task OidcExchange_SameCodeTwice_SecondFails()
|
||||
{
|
||||
// First, trigger a valid callback
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
var location = callbackResponse.Headers.Location?.ToString()!;
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"]!;
|
||||
|
||||
// First exchange succeeds
|
||||
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Second exchange with same code fails
|
||||
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-invalid-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
|
||||
{
|
||||
// Use the mock's state that returns a different subject
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Should succeed (no 2FA since we skipped it in setup)
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// No 2FA, so should have tokens directly
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
|
||||
{
|
||||
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
|
||||
{
|
||||
// OIDC should be disabled when essential config (IssuerUrl) is missing
|
||||
await _factory.SetOidcIssuerUrlAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
|
||||
{
|
||||
// Clear the authorized subject — any OIDC user should be allowed
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
// Use the "wrong subject" state — this returns a different subject than the authorized one
|
||||
// With no linked subject, it should still succeed
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task OidcExchange_RandomCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-random-nonexistent-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(19)]
|
||||
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = "some-token",
|
||||
code = "123456"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(23)]
|
||||
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
|
||||
{
|
||||
pinId = 12345
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(24)]
|
||||
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(25)]
|
||||
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(26)]
|
||||
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// Custom factory that replaces IOidcAuthService with a mock for testing.
|
||||
/// </summary>
|
||||
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OidcWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Clean up any existing DataContext DB from previous test runs.
|
||||
// DataContext.CreateStaticInstance() uses ConfigurationPathProvider which
|
||||
// resolves to {AppContext.BaseDirectory}/config/cleanuparr.db.
|
||||
// We need to ensure a clean state for our tests.
|
||||
var configDir = Cleanuparr.Shared.Helpers.ConfigurationPathProvider.GetConfigPath();
|
||||
var dataDbPath = Path.Combine(configDir, "cleanuparr.db");
|
||||
if (File.Exists(dataDbPath))
|
||||
{
|
||||
try { File.Delete(dataDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Replace IOidcAuthService with mock
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
|
||||
// Ensure DB is created using a minimal isolated context (not the full app DI container)
|
||||
// to avoid any residual static state contamination.
|
||||
using var db = new UsersContext(
|
||||
new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options);
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables OIDC on the user in the UsersContext database.
|
||||
/// </summary>
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.IssuerUrl = issuerUrl;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcAuthorizedSubjectAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.AuthorizedSubject = subject;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock OIDC auth service that simulates IdP behavior without network calls.
|
||||
/// </summary>
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string ValidState = "mock-valid-state";
|
||||
public const string WrongSubjectState = "mock-wrong-subject-state";
|
||||
public const string AuthorizedSubject = "mock-authorized-subject-123";
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
|
||||
State = ValidState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == ValidState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = AuthorizedSubject,
|
||||
PreferredUsername = "testuser",
|
||||
Email = "testuser@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
if (state == WrongSubjectState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = "wrong-subject-that-doesnt-match",
|
||||
PreferredUsername = "wronguser",
|
||||
Email = "wrong@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
public class SensitiveDataHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithPlaceholder_ReturnsTrue()
|
||||
{
|
||||
SensitiveDataHelper.Placeholder.IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithAppriseStyledPlaceholder_ReturnsTrue()
|
||||
{
|
||||
$"discord://{SensitiveDataHelper.Placeholder}".IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithNull_ReturnsFalse()
|
||||
{
|
||||
((string?)null).IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithEmptyString_ReturnsFalse()
|
||||
{
|
||||
"".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithRealValue_ReturnsFalse()
|
||||
{
|
||||
"my-secret-api-key-123".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("discord://webhook_id/webhook_token", "discord://••••••••")]
|
||||
[InlineData("slack://tokenA/tokenB/tokenC", "slack://••••••••")]
|
||||
[InlineData("mailto://user:pass@gmail.com", "mailto://••••••••")]
|
||||
[InlineData("json+http://user:pass@host/path", "json+http://••••••••")]
|
||||
public void MaskAppriseUrls_SingleUrl_MasksCorrectly(string input, string expected)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultipleUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1 slack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
result.ShouldNotContain("token1");
|
||||
result.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultilineUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1\nslack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void MaskAppriseUrls_EmptyOrNull_ReturnsAsIs(string? input)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that placeholder values are correctly handled on the input side:
|
||||
/// - UPDATE operations preserve the existing DB value when a placeholder is sent
|
||||
/// - CREATE operations reject placeholder values
|
||||
/// - TEST operations reject placeholder values
|
||||
/// </summary>
|
||||
public class SensitiveDataInputTests
|
||||
{
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
#region ArrInstanceRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithPlaceholderApiKey_PreservesExistingValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Updated Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("original-secret-key");
|
||||
existingInstance.Name.ShouldBe("Updated Sonarr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithRealApiKey_UpdatesValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "brand-new-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("brand-new-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithPlaceholderApiKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToEntity(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key-123",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var entity = request.ToEntity(Guid.NewGuid());
|
||||
entity.ApiKey.ShouldBe("real-api-key-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestArrInstanceRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndNoResolvedKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToTestInstance());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndResolvedKey_UsesResolvedKey()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
InstanceId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance("resolved-api-key-from-db");
|
||||
instance.ApiKey.ShouldBe("resolved-api-key-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance();
|
||||
instance.ApiKey.ShouldBe("real-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateDownloadClientRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithPlaceholderPassword_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "Updated qBit",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("original-secret-password");
|
||||
result.Name.ShouldBe("Updated qBit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithRealPassword_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = "new-password-123",
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("new-password-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateDownloadClientRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithPlaceholderPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithNullPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = null,
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestDownloadClientRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndNoResolvedPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
Should.Throw<ValidationException>(() => request.ToTestConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndResolvedPassword_UsesResolvedPassword()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
ClientId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig("resolved-password-from-db");
|
||||
config.Password.ShouldBe("resolved-password-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig();
|
||||
config.Password.ShouldBe("real-password");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateOidcConfigRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithPlaceholderClientSecret_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = Placeholder,
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("original-secret");
|
||||
existingConfig.ProviderName.ShouldBe("Keycloak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithRealClientSecret_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "brand-new-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("brand-new-secret");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
|
||||
/// during JSON serialization — this is what controls the API response output.
|
||||
/// </summary>
|
||||
public class SensitiveDataResolverTests
|
||||
{
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
public SensitiveDataResolverTests()
|
||||
{
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()),
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
}
|
||||
|
||||
#region ArrInstance
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_ApiKey_IsMasked()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NonSensitiveFields_AreVisible()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ExternalUrl = new Uri("https://sonarr.example.com"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989");
|
||||
doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NullApiKey_RemainsNull()
|
||||
{
|
||||
// ApiKey is required, but let's test with the DTO which might handle null
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = null!,
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceDto
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceDto_ApiKey_IsMasked()
|
||||
{
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Radarr",
|
||||
Url = "http://radarr:7878",
|
||||
ApiKey = "dto-secret-api-key-67890",
|
||||
Version = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DownloadClientConfig
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Password_IsMasked()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Username_IsVisible()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("admin");
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_NullPassword_RemainsNull()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifiarrConfig
|
||||
|
||||
[Fact]
|
||||
public void NotifiarrConfig_ApiKey_IsMasked()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "notifiarr-api-key-secret",
|
||||
ChannelId = "123456789"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DiscordConfig
|
||||
|
||||
[Fact]
|
||||
public void DiscordConfig_WebhookUrl_IsMasked()
|
||||
{
|
||||
var config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token",
|
||||
Username = "Cleanuparr Bot",
|
||||
AvatarUrl = "https://example.com/avatar.png"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot");
|
||||
doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TelegramConfig
|
||||
|
||||
[Fact]
|
||||
public void TelegramConfig_BotToken_IsMasked()
|
||||
{
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz",
|
||||
ChatId = "-1001234567890",
|
||||
TopicId = "42",
|
||||
SendSilently = true
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890");
|
||||
doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NtfyConfig
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_PasswordAndAccessToken_AreMasked()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.BasicAuth,
|
||||
Username = "ntfy-user",
|
||||
Password = "ntfy-secret-password",
|
||||
AccessToken = "ntfy-access-token-secret",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com");
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_NullPasswordAndAccessToken_RemainNull()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PushoverConfig
|
||||
|
||||
[Fact]
|
||||
public void PushoverConfig_ApiTokenAndUserKey_AreMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "pushover-api-token-secret",
|
||||
UserKey = "pushover-user-key-secret",
|
||||
Priority = PushoverPriority.Normal,
|
||||
Devices = ["iphone", "desktop"]
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GotifyConfig
|
||||
|
||||
[Fact]
|
||||
public void GotifyConfig_ApplicationToken_IsMasked()
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "gotify-app-token-secret",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AppriseConfig
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_Key_IsMasked_WithFullMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "apprise-config-key-secret",
|
||||
Tags = "urgent",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Cli,
|
||||
ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString();
|
||||
maskedUrls.ShouldContain("discord://••••••••");
|
||||
maskedUrls.ShouldContain("slack://••••••••");
|
||||
maskedUrls.ShouldNotContain("webhook_id");
|
||||
maskedUrls.ShouldNotContain("webhook_token");
|
||||
maskedUrls.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_NullServiceUrls_RemainsNull()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "some-key",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Polymorphic serialization (as used in NotificationProviderResponse)
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_NotifiarrConfig_StillMasked()
|
||||
{
|
||||
// The notification providers endpoint casts configs to `object`.
|
||||
// Verify that the resolver still masks when serializing as a concrete type at runtime.
|
||||
object config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "my-secret-notifiarr-key",
|
||||
ChannelId = "987654321"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_DiscordConfig_StillMasked()
|
||||
{
|
||||
object config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123/secret",
|
||||
Username = "Bot"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge cases
|
||||
|
||||
[Fact]
|
||||
public void EmptySensitiveString_IsMasked_NotReturnedEmpty()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "",
|
||||
ChannelId = "123"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Even empty strings get masked to the placeholder
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleSensitiveFields_AllMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "token-abc-123",
|
||||
UserKey = "user-key-xyz-789",
|
||||
Priority = PushoverPriority.High,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
public sealed class PriorityOrderer : ITestCaseOrderer
|
||||
{
|
||||
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
|
||||
where TTestCase : ITestCase
|
||||
{
|
||||
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var priority = testCase.TestMethod.Method
|
||||
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
|
||||
.FirstOrDefault()
|
||||
?.GetNamedArgument<int>("Priority") ?? 0;
|
||||
|
||||
if (!sortedMethods.TryGetValue(priority, out var list))
|
||||
{
|
||||
list = [];
|
||||
sortedMethods[priority] = list;
|
||||
}
|
||||
|
||||
list.Add(testCase);
|
||||
}
|
||||
|
||||
foreach (var list in sortedMethods.Values)
|
||||
{
|
||||
foreach (var testCase in list)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Auth integration tests share the file-system config directory (users.db via
|
||||
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
|
||||
/// forces sequential execution and prevents inter-factory interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Auth Integration Tests")]
|
||||
public class AuthIntegrationTestsCollection { }
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class TestPriorityAttribute : Attribute
|
||||
{
|
||||
public int Priority { get; }
|
||||
|
||||
public TestPriorityAttribute(int priority)
|
||||
{
|
||||
Priority = priority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
Loaded 100 of 1296 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user