mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
7
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
7
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
@@ -7,6 +7,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
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:
|
||||
|
||||
19
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
19
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
@@ -7,6 +7,25 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
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
.github/ISSUE_TEMPLATE/3-help.yml
vendored
7
.github/ISSUE_TEMPLATE/3-help.yml
vendored
@@ -7,6 +7,13 @@ 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:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -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
|
||||
30
.github/actions/vault-secrets/action.yml
vendored
Normal file
30
.github/actions/vault-secrets/action.yml
vendored
Normal file
@@ -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 }}
|
||||
60
.github/workflows/build-docker.yml
vendored
60
.github/workflows/build-docker.yml
vendored
@@ -1,14 +1,26 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
workflow_dispatch:
|
||||
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
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
@@ -27,15 +39,37 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
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
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
@@ -58,6 +92,12 @@ jobs:
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
@@ -101,6 +141,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push docker image
|
||||
id: docker-build
|
||||
timeout-minutes: 15
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -114,11 +155,12 @@ jobs:
|
||||
VERSION=${{ env.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
outputs: |
|
||||
type=image
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: true
|
||||
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
${{ env.githubTags }}
|
||||
# Enable BuildKit cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
139
.github/workflows/build-executable.yml
vendored
139
.github/workflows/build-executable.yml
vendored
@@ -1,40 +1,55 @@
|
||||
name: Build Executables
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 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: Gate
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' }}
|
||||
run: |
|
||||
echo "This workflow only runs on tag events or manual dispatch. Pipeline finished."
|
||||
exit 0
|
||||
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
|
||||
# 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
|
||||
|
||||
repoFullName=${{ github.repository }}
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
@@ -58,27 +73,28 @@ jobs:
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
|
||||
- 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 ${{ secrets.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
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
|
||||
@@ -86,52 +102,25 @@ jobs:
|
||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||
|
||||
- name: Build win-x64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
- 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: Build linux-x64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Build linux-arm64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Build osx-x64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Build osx-arm64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Zip win-x64
|
||||
- name: Zip artifact
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||
|
||||
- name: Zip linux-x64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/
|
||||
|
||||
- name: Zip linux-arm64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/
|
||||
|
||||
- name: Zip osx-x64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/
|
||||
|
||||
- name: Zip osx-arm64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/
|
||||
|
||||
- name: Upload artifacts
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cleanuparr-executables
|
||||
path: |
|
||||
./artifacts/*.zip
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
46
.github/workflows/build-frontend.yml
vendored
Normal file
46
.github/workflows/build-frontend.yml
vendored
Normal file
@@ -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
|
||||
@@ -1,28 +1,47 @@
|
||||
name: Build macOS ARM Installer
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-macos-arm-installer:
|
||||
name: Build macOS ARM Installer
|
||||
runs-on: macos-14 # ARM runner for Apple Silicon
|
||||
|
||||
build-macos-installer:
|
||||
name: Build macOS ${{ matrix.arch }} Installer
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-13
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-14
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
|
||||
# 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
|
||||
@@ -30,9 +49,9 @@ jobs:
|
||||
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
|
||||
@@ -58,18 +77,11 @@ jobs:
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -81,16 +93,16 @@ jobs:
|
||||
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 ARM executable
|
||||
- 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 osx-arm64 \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
@@ -103,17 +115,17 @@ jobs:
|
||||
/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
|
||||
@@ -124,16 +136,16 @@ jobs:
|
||||
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/
|
||||
|
||||
@@ -141,12 +153,12 @@ jobs:
|
||||
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
|
||||
@@ -158,14 +170,14 @@ jobs:
|
||||
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"?>
|
||||
@@ -196,7 +208,7 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -228,7 +240,7 @@ jobs:
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<string>${{ matrix.min_os_version }}</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
@@ -245,7 +257,7 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
@@ -255,96 +267,96 @@ jobs:
|
||||
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 [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64.pkg"
|
||||
|
||||
# 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-arm64-dev.pkg"
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
fi
|
||||
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
@@ -353,14 +365,12 @@ jobs:
|
||||
--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-arm64-installer
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
366
.github/workflows/build-macos-intel-installer.yml
vendored
366
.github/workflows/build-macos-intel-installer.yml
vendored
@@ -1,366 +0,0 @@
|
||||
name: Build macOS Intel Installer
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-macos-intel-installer:
|
||||
name: Build macOS Intel Installer
|
||||
runs-on: macos-13 # Intel runner
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$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: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- 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 Intel 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 osx-x64 \
|
||||
--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>10.15</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 [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel-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-intel-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
41
.github/workflows/build-windows-installer.yml
vendored
41
.github/workflows/build-windows-installer.yml
vendored
@@ -1,11 +1,13 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
@@ -17,9 +19,13 @@ jobs:
|
||||
run: |
|
||||
$repoFullName = "${{ github.repository }}"
|
||||
$ref = "${{ github.ref }}"
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if ($ref -match "^refs/tags/") {
|
||||
$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 {
|
||||
@@ -27,15 +33,15 @@ jobs:
|
||||
$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 "executableName=Cleanuparr.Api" >> $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
|
||||
@@ -55,18 +61,11 @@ jobs:
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
36
.github/workflows/cloudflare-pages-blocklists.yml
vendored
Normal file
36
.github/workflows/cloudflare-pages-blocklists.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
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: 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
|
||||
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
@@ -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
|
||||
45
.github/workflows/dependency-review.yml
vendored
Normal file
45
.github/workflows/dependency-review.yml
vendored
Normal file
@@ -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
|
||||
9
.github/workflows/docs.yml
vendored
9
.github/workflows/docs.yml
vendored
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,11 +22,12 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
|
||||
243
.github/workflows/release.yml
vendored
243
.github/workflows/release.yml
vendored
@@ -8,8 +8,32 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: true
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
required: false
|
||||
default: ''
|
||||
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:
|
||||
# Validate release
|
||||
@@ -19,7 +43,7 @@ jobs:
|
||||
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
|
||||
@@ -32,55 +56,127 @@ jobs:
|
||||
release_version=${GITHUB_REF##refs/tags/}
|
||||
app_version=${release_version#v}
|
||||
is_tag=true
|
||||
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
else
|
||||
# Manual workflow with version
|
||||
app_version="${{ github.event.inputs.version }}"
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
else
|
||||
# Manual workflow without version
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
# 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"
|
||||
|
||||
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
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.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
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.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:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: validate
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.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 Intel installer
|
||||
build-macos-intel:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-macos-intel-installer.yml
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.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 macOS ARM installer
|
||||
build-macos-arm:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-macos-arm-installer.yml
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.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-intel, build-macos-arm]
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
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
|
||||
@@ -93,72 +189,99 @@ jobs:
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download all artifacts
|
||||
- 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 "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: Cleanuparr ${{ needs.validate.outputs.release_version }}
|
||||
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
|
||||
./artifacts/*.zip
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
|
||||
needs: [validate, test, 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
|
||||
|
||||
# Check job results
|
||||
if [[ "${{ needs.build-executables.result }}" == "success" ]]; then
|
||||
echo "✅ **Portable Executables**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Portable Executables**: ${{ needs.build-executables.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-windows-installer.result }}" == "success" ]]; then
|
||||
echo "✅ **Windows Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Windows Installer**: ${{ needs.build-windows-installer.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-macos-intel.result }}" == "success" ]]; then
|
||||
echo "✅ **macOS Intel Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **macOS Intel Installer**: ${{ needs.build-macos-intel.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-macos-arm.result }}" == "success" ]]; then
|
||||
echo "✅ **macOS ARM Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **macOS ARM Installer**: ${{ needs.build-macos-arm.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
# 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 "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
|
||||
99
.github/workflows/test.yml
vendored
Normal file
99
.github/workflows/test.yml
vendored
Normal file
@@ -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: ${{ github.workflow }}-${{ 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@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- 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
|
||||
66
.github/workflows/version-info.yml
vendored
Normal file
66
.github/workflows/version-info.yml
vendored
Normal file
@@ -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}"
|
||||
325
CONTRIBUTING.md
Normal file
325
CONTRIBUTING.md
Normal file
@@ -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 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.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!
|
||||
3
Cloudflare/_headers
Normal file
3
Cloudflare/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
@@ -0,0 +1,2 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
44
README.md
44
README.md
@@ -2,6 +2,11 @@ _Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
|
||||
@@ -15,31 +20,39 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - 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 **Content Blocker**.
|
||||
> - 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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
|
||||
## 🎯 Supported Applications
|
||||
|
||||
### *Arr Applications
|
||||
- **Sonarr** (TV Shows)
|
||||
- **Radarr** (Movies)
|
||||
- **Lidarr** (Music)
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr**
|
||||
|
||||
### Download Clients
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker** (Linux, Windows, macOS)
|
||||
- **Windows** (Native installer)
|
||||
- **macOS** (Intel & Apple Silicon)
|
||||
- **Linux** (Portable executable)
|
||||
- **Unraid** (Community Apps)
|
||||
- **Docker**
|
||||
- **Windows**
|
||||
- **macOS**
|
||||
- **Linux**
|
||||
- **Unraid**
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -55,7 +68,7 @@ docker run -d --name cleanuparr \
|
||||
ghcr.io/cleanuparr/cleanuparr:latest
|
||||
```
|
||||
|
||||
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
|
||||
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/).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
@@ -74,6 +87,17 @@ http://localhost:11011
|
||||
- **💬 [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
|
||||
|
||||
## 🤝 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
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-build
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci && npm install -g @angular/cli
|
||||
# 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/ .
|
||||
@@ -28,14 +30,17 @@ EXPOSE 11011
|
||||
# Copy source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Restore dependencies
|
||||
# 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
|
||||
|
||||
# Build and publish
|
||||
RUN dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
# 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
|
||||
|
||||
@@ -19,31 +19,28 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Application\Cleanuparr.Application.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.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.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<!-- API-related packages -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,6 @@ public class EventsController : ControllerBase
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
events = events
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToList();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<AppEvent>
|
||||
{
|
||||
|
||||
@@ -76,63 +76,23 @@ public class JobsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/stop")]
|
||||
public async Task<IActionResult> StopJob(JobType jobType)
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.StopJob(jobType);
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to stop job '{jobType}'");
|
||||
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error stopping job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while stopping job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/pause")]
|
||||
public async Task<IActionResult> PauseJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.PauseJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to pause job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' paused successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error pausing job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while pausing job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/resume")]
|
||||
public async Task<IActionResult> ResumeJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.ResumeJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to resume job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' resumed successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resuming job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while resuming job '{jobType}'");
|
||||
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ManualEventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public ManualEventsController(EventsContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual events with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] bool? isResolved = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
||||
|
||||
var query = _context.ManualEvents.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (isResolved.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.IsResolved == isResolved.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
|
||||
// Apply date range filters
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp >= fromDate.Value);
|
||||
}
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp <= toDate.Value);
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
// Count total matching records for pagination
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Calculate pagination
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
// Get paginated data
|
||||
var events = await query
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<ManualEvent>
|
||||
{
|
||||
Items = events,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific manual event by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ManualEvent>> GetManualEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.ManualEvents.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(eventEntity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a manual event as resolved
|
||||
/// </summary>
|
||||
[HttpPost("{id}/resolve")]
|
||||
public async Task<ActionResult> ResolveManualEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.ManualEvents.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
eventEntity.IsResolved = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual event statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<object>> GetManualEventStats()
|
||||
{
|
||||
var stats = new
|
||||
{
|
||||
TotalEvents = await _context.ManualEvents.CountAsync(),
|
||||
UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved),
|
||||
ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved),
|
||||
EventsBySeverity = await _context.ManualEvents
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync(),
|
||||
UnresolvedBySeverity = await _context.ManualEvents
|
||||
.Where(e => !e.IsResolved)
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync()
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique severities for manual events
|
||||
/// </summary>
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers cleanup of old resolved events
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
var deletedCount = await _context.ManualEvents
|
||||
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok(new { DeletedCount = deletedCount });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,18 +15,15 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly ArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
ArrClientFactory arrClientFactory)
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
@@ -179,7 +177,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
||||
await sonarrClient.TestConnectionAsync(instance);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
@@ -211,7 +209,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
||||
await radarrClient.TestConnectionAsync(instance);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
@@ -243,7 +241,7 @@ public class StatusController : ControllerBase
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
|
||||
await lidarrClient.TestConnectionAsync(instance);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Text;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -15,15 +14,21 @@ public static class ApiDI
|
||||
{
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
|
||||
// Make JsonSerializerOptions available for injection
|
||||
services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
|
||||
|
||||
// Add API-specific services
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
@@ -34,25 +39,12 @@ public static class ApiDI
|
||||
.AddSignalR()
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
// Add health status broadcaster
|
||||
services.AddHostedService<HealthStatusBroadcaster>();
|
||||
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "Cleanuparr API",
|
||||
Version = "v1",
|
||||
Description = "API for managing media downloads and cleanups",
|
||||
Contact = new OpenApiContact
|
||||
{
|
||||
Name = "Cleanuparr Team"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -67,33 +59,15 @@ public static class ApiDI
|
||||
// Serve static files with caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
// Cache static assets for 30 days
|
||||
// if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css"))
|
||||
// {
|
||||
// ctx.Context.Response.Headers.CacheControl = "public,max-age=2592000";
|
||||
// }
|
||||
}
|
||||
OnPrepareResponse = _ => {}
|
||||
});
|
||||
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("v1/swagger.json", "Cleanuparr API v1");
|
||||
options.RoutePrefix = "swagger";
|
||||
options.DocumentTitle = "Cleanuparr API Documentation";
|
||||
});
|
||||
}
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -157,12 +131,12 @@ public static class ApiDI
|
||||
icons = new[]
|
||||
{
|
||||
new {
|
||||
src = "assets/icons/icon-192x192.png",
|
||||
src = "icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "assets/icons/icon-512x512.png",
|
||||
src = "icons/icon-512x512.png",
|
||||
sizes = "512x512",
|
||||
type = "image/png"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -12,82 +7,10 @@ public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
||||
{
|
||||
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
|
||||
Log.Logger = LoggingConfigManager
|
||||
.CreateLoggerConfiguration()
|
||||
.CreateLogger();
|
||||
|
||||
return builder.ClearProviders().AddSerilog();
|
||||
}
|
||||
|
||||
public static LoggerConfiguration GetDefaultLoggerConfiguration()
|
||||
{
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
|
||||
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
|
||||
|
||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
||||
|
||||
// Determine job name padding
|
||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
|
||||
int jobPadding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Determine instance name padding
|
||||
List<string> categoryNames = [
|
||||
InstanceType.Sonarr.ToString(),
|
||||
InstanceType.Radarr.ToString(),
|
||||
InstanceType.Lidarr.ToString(),
|
||||
InstanceType.Readarr.ToString(),
|
||||
InstanceType.Whisparr.ToString(),
|
||||
"SYSTEM"
|
||||
];
|
||||
int catPadding = categoryNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Apply padding values to templates
|
||||
string consoleTemplate = consoleOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("CAT_PAD", catPadding.ToString());
|
||||
|
||||
string fileTemplate = fileOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("CAT_PAD", catPadding.ToString());
|
||||
|
||||
// Configure base logger with dynamic level control
|
||||
logConfig
|
||||
.MinimumLevel.Is(LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
|
||||
|
||||
// Create the logs directory
|
||||
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
|
||||
if (!Directory.Exists(logsPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logsPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"Failed to create log directory | {logsPath}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Add main log file
|
||||
logConfig.WriteTo.File(
|
||||
path: Path.Combine(logsPath, "cleanuparr-.txt"),
|
||||
formatter: new ExpressionTemplate(fileTemplate),
|
||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true,
|
||||
shared: true
|
||||
);
|
||||
|
||||
logConfig
|
||||
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
|
||||
|
||||
return logConfig;
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,17 @@ public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients(configuration)
|
||||
.AddSingleton<MemoryCache>()
|
||||
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
||||
.AddServices()
|
||||
.AddHealthServices()
|
||||
.AddQuartzServices(configuration)
|
||||
.AddNotifications(configuration)
|
||||
.AddNotifications()
|
||||
.AddMassTransit(config =>
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
@@ -34,7 +35,8 @@ public static class MainDI
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
@@ -43,6 +45,7 @@ public static class MainDI
|
||||
{
|
||||
cfg.ConfigureJsonSerializerOptions(options =>
|
||||
{
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
|
||||
@@ -69,7 +72,8 @@ public static class MainDI
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class NotificationsDI
|
||||
{
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
|
||||
services
|
||||
// Notification configs are now managed through ConfigManager
|
||||
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddTransient<INotificationProvider, NotifiarrProvider>()
|
||||
.AddTransient<IAppriseProxy, AppriseProxy>()
|
||||
.AddTransient<INotificationProvider, AppriseProvider>()
|
||||
.AddTransient<INotificationPublisher, NotificationPublisher>()
|
||||
.AddTransient<INotificationFactory, NotificationFactory>()
|
||||
.AddTransient<NotificationService>();
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
.AddScoped<INotificationPublisher, NotificationPublisher>()
|
||||
.AddScoped<NotificationService>();
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
@@ -11,13 +10,13 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -25,34 +24,38 @@ public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddSingleton<IEncryptionService, AesEncryptionService>()
|
||||
.AddTransient<SensitiveDataJsonConverter>()
|
||||
.AddTransient<EventsContext>()
|
||||
.AddTransient<DataContext>()
|
||||
.AddTransient<EventPublisher>()
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
// API services
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrClient, WhisparrClient>()
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
// Core services
|
||||
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddTransient<CertificateValidationService>()
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<ReadarrClient>()
|
||||
.AddTransient<WhisparrClient>()
|
||||
.AddTransient<ArrClientFactory>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<DownloadCleaner>()
|
||||
.AddTransient<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddTransient<IDownloadHunter, DownloadHunter>()
|
||||
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddTransient<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddTransient<UnixHardLinkFileService>()
|
||||
.AddTransient<WindowsHardLinkFileService>()
|
||||
.AddTransient<ArrQueueIterator>()
|
||||
.AddTransient<DownloadServiceFactory>()
|
||||
.AddTransient<IStriker, Striker>()
|
||||
.AddSingleton<BlocklistProvider>();
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record ArrInstanceRequest
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[Required]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId) => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
};
|
||||
|
||||
public void ApplyTo(ArrInstance instance)
|
||||
{
|
||||
instance.Enabled = Enabled;
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ApiKey = ApiKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record TestArrInstanceRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateArrConfigRequest
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
public Task<IActionResult> GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr);
|
||||
|
||||
[HttpGet("radarr")]
|
||||
public Task<IActionResult> GetRadarrConfig() => GetArrConfig(InstanceType.Radarr);
|
||||
|
||||
[HttpGet("lidarr")]
|
||||
public Task<IActionResult> GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr);
|
||||
|
||||
[HttpGet("readarr")]
|
||||
public Task<IActionResult> GetReadarrConfig() => GetArrConfig(InstanceType.Readarr);
|
||||
|
||||
[HttpGet("whisparr")]
|
||||
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPut("radarr")]
|
||||
public Task<IActionResult> UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPut("lidarr")]
|
||||
public Task<IActionResult> UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPut("readarr")]
|
||||
public Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPut("whisparr")]
|
||||
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPost("sonarr/instances")]
|
||||
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPut("sonarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Sonarr, id, request);
|
||||
|
||||
[HttpDelete("sonarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteSonarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Sonarr, id);
|
||||
|
||||
[HttpPost("radarr/instances")]
|
||||
public Task<IActionResult> CreateRadarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPut("radarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Radarr, id, request);
|
||||
|
||||
[HttpDelete("radarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteRadarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Radarr, id);
|
||||
|
||||
[HttpPost("lidarr/instances")]
|
||||
public Task<IActionResult> CreateLidarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPut("lidarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Lidarr, id, request);
|
||||
|
||||
[HttpDelete("lidarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteLidarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Lidarr, id);
|
||||
|
||||
[HttpPost("readarr/instances")]
|
||||
public Task<IActionResult> CreateReadarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPut("readarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Readarr, id, request);
|
||||
|
||||
[HttpDelete("readarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteReadarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Readarr, id);
|
||||
|
||||
[HttpPost("whisparr/instances")]
|
||||
public Task<IActionResult> CreateWhisparrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPut("whisparr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Whisparr, id, request);
|
||||
|
||||
[HttpDelete("whisparr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
[HttpPost("sonarr/instances/test")]
|
||||
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPost("radarr/instances/test")]
|
||||
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPost("lidarr/instances/test")]
|
||||
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPost("readarr/instances/test")]
|
||||
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPost("whisparr/instances/test")]
|
||||
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
config.FailedImportMaxStrikes = request.FailedImportMaxStrikes;
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Message = $"{type} configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save {Type} configuration", type);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CreateArrInstance(InstanceType type, ArrInstanceRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = request.ToEntity(config.Id);
|
||||
await _dataContext.ArrInstances.AddAsync(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create {Type} instance", type);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return NotFound($"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
request.ApplyTo(instance);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> DeleteArrInstance(InstanceType type, Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return NotFound($"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
config.Instances.Remove(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testInstance = request.ToTestInstance();
|
||||
var client = _arrClientFactory.GetClient(type);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
|
||||
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigActionName(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||
InstanceType.Radarr => nameof(GetRadarrConfig),
|
||||
InstanceType.Lidarr => nameof(GetLidarrConfig),
|
||||
InstanceType.Readarr => nameof(GetReadarrConfig),
|
||||
InstanceType.Whisparr => nameof(GetWhisparrConfig),
|
||||
_ => nameof(GetSonarrConfig),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
|
||||
namespace Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string? BlacklistPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies the request to the provided configuration instance.
|
||||
/// </summary>
|
||||
public BlacklistSyncConfig ApplyTo(BlacklistSyncConfig config)
|
||||
{
|
||||
config.Enabled = Enabled;
|
||||
config.BlacklistPath = BlacklistPath;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public bool HasPathChanged(string? currentPath)
|
||||
=> !string.Equals(currentPath, BlacklistPath, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class BlacklistSyncConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<BlacklistSyncConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public BlacklistSyncConfigController(
|
||||
ILogger<BlacklistSyncConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("blacklist_sync")]
|
||||
public async Task<IActionResult> GetBlacklistSyncConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("blacklist_sync")]
|
||||
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] UpdateBlacklistSyncConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.BlacklistSyncConfigs
|
||||
.FirstAsync();
|
||||
|
||||
bool enabledChanged = config.Enabled != request.Enabled;
|
||||
bool becameEnabled = !config.Enabled && request.Enabled;
|
||||
bool pathChanged = request.HasPathChanged(config.BlacklistPath);
|
||||
|
||||
request.ApplyTo(config);
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
if (enabledChanged)
|
||||
{
|
||||
if (becameEnabled)
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
|
||||
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, config.CronExpression);
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
|
||||
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
}
|
||||
else if (pathChanged && config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("BlacklistSynchronizer path changed");
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
|
||||
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public record SeedingRuleRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; init; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to delete the source files when cleaning the download.
|
||||
/// </summary>
|
||||
public bool DeleteSourceFiles { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0 0 * * * ?";
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule.
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public List<SeedingRuleRequest> Categories { get; init; } = [];
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether unlinked download handling is enabled.
|
||||
/// </summary>
|
||||
public bool UnlinkedEnabled { get; init; }
|
||||
|
||||
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
|
||||
|
||||
public bool UnlinkedUseTag { get; init; }
|
||||
|
||||
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
|
||||
|
||||
public List<string> UnlinkedCategories { get; init; } = [];
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadCleanerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public DownloadCleanerConfigController(
|
||||
ILogger<DownloadCleanerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("download_cleaner")]
|
||||
public async Task<IActionResult> GetDownloadCleanerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("download_cleaner")]
|
||||
public async Task<IActionResult> UpdateDownloadCleanerConfig([FromBody] UpdateDownloadCleanerConfigRequest newConfigDto)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (newConfigDto is null)
|
||||
{
|
||||
throw new ValidationException("Request body cannot be null");
|
||||
}
|
||||
|
||||
// Validate cron expression format
|
||||
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
// Get existing configuration
|
||||
var oldConfig = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.FirstAsync();
|
||||
|
||||
oldConfig.Enabled = newConfigDto.Enabled;
|
||||
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
|
||||
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
oldConfig.Categories.Clear();
|
||||
|
||||
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
_dataContext.SeedingRules.Add(new SeedingRule
|
||||
{
|
||||
Name = categoryDto.Name,
|
||||
MaxRatio = categoryDto.MaxRatio,
|
||||
MinSeedTime = categoryDto.MinSeedTime,
|
||||
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
|
||||
DownloadCleanerConfigId = oldConfig.Id
|
||||
});
|
||||
}
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
|
||||
|
||||
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||
jobType.ToString(), config.CronExpression);
|
||||
|
||||
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||
await _jobManagementService.StopJob(jobType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record CreateDownloadClientRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToEntity() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record TestDownloadClientRequest
|
||||
{
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateDownloadClientRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
public DownloadClientType Type { get; init; }
|
||||
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = Host,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class DownloadClientController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadClientController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||
|
||||
public DownloadClientController(
|
||||
ILogger<DownloadClientController> logger,
|
||||
DataContext dataContext,
|
||||
IDynamicHttpClientFactory dynamicHttpClientFactory,
|
||||
IDownloadServiceFactory downloadServiceFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_dynamicHttpClientFactory = dynamicHttpClientFactory;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
}
|
||||
|
||||
[HttpGet("download_client")]
|
||||
public async Task<IActionResult> GetDownloadClientConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var clients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
clients = clients
|
||||
.OrderBy(c => c.TypeName)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { clients });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("download_client")]
|
||||
public async Task<IActionResult> CreateDownloadClientConfig([FromBody] CreateDownloadClientRequest newClient)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
newClient.Validate();
|
||||
|
||||
var clientConfig = newClient.ToEntity();
|
||||
|
||||
_dataContext.DownloadClients.Add(clientConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download client");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("download_client/{id}")]
|
||||
public async Task<IActionResult> UpdateDownloadClientConfig(Guid id, [FromBody] UpdateDownloadClientRequest updatedClient)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
updatedClient.Validate();
|
||||
|
||||
var existingClient = await _dataContext.DownloadClients
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
return NotFound($"Download client with ID {id} not found");
|
||||
}
|
||||
|
||||
var clientToPersist = updatedClient.ApplyTo(existingClient);
|
||||
|
||||
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(clientToPersist);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("download_client/{id}")]
|
||||
public async Task<IActionResult> DeleteDownloadClientConfig(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingClient = await _dataContext.DownloadClients
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
return NotFound($"Download client with ID {id} not found");
|
||||
}
|
||||
|
||||
_dataContext.DownloadClients.Remove(existingClient);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var clientName = $"DownloadClient_{id}";
|
||||
_dynamicHttpClientFactory.UnregisterConfiguration(clientName);
|
||||
|
||||
_logger.LogInformation("Removed HTTP client configuration for deleted download client {ClientName}", clientName);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("download_client/test")]
|
||||
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
var testConfig = request.ToTestConfig();
|
||||
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
|
||||
var healthResult = await downloadService.HealthCheckAsync();
|
||||
|
||||
if (healthResult.IsHealthy)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
Message = $"Connection to {request.TypeName} successful",
|
||||
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Serilog.Events;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateGeneralConfigRequest
|
||||
{
|
||||
public bool DisplaySupportBanner { get; init; } = true;
|
||||
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
public ushort HttpMaxRetries { get; init; }
|
||||
|
||||
public ushort HttpTimeout { get; init; } = 100;
|
||||
|
||||
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
|
||||
|
||||
public bool SearchEnabled { get; init; } = true;
|
||||
|
||||
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
|
||||
|
||||
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public UpdateLoggingConfigRequest Log { get; init; } = new();
|
||||
|
||||
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
|
||||
{
|
||||
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
|
||||
existingConfig.DryRun = DryRun;
|
||||
existingConfig.HttpMaxRetries = HttpMaxRetries;
|
||||
existingConfig.HttpTimeout = HttpTimeout;
|
||||
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||
existingConfig.SearchEnabled = SearchEnabled;
|
||||
existingConfig.SearchDelay = SearchDelay;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
|
||||
|
||||
Validate(existingConfig);
|
||||
|
||||
ApplySideEffects(existingConfig, services, logger, loggingChanged);
|
||||
|
||||
return existingConfig;
|
||||
}
|
||||
|
||||
private static void Validate(GeneralConfig config)
|
||||
{
|
||||
if (config.HttpTimeout is 0)
|
||||
{
|
||||
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
|
||||
}
|
||||
|
||||
config.Log.Validate();
|
||||
}
|
||||
|
||||
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
|
||||
{
|
||||
var dynamicHttpClientFactory = services.GetRequiredService<IDynamicHttpClientFactory>();
|
||||
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(config);
|
||||
|
||||
logger.LogInformation("Updated all HTTP client configurations with new general settings");
|
||||
|
||||
if (!loggingChanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Log.LevelOnlyChange)
|
||||
{
|
||||
logger.LogCritical("Setting global log level to {level}", config.Log.Level);
|
||||
LoggingConfigManager.SetLogLevel(config.Log.Level);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical("Reconfiguring logger due to configuration changes");
|
||||
LoggingConfigManager.ReconfigureLogging(config);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateLoggingConfigRequest
|
||||
{
|
||||
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
|
||||
|
||||
public ushort RollingSizeMB { get; init; } = 10;
|
||||
|
||||
public ushort RetainedFileCount { get; init; } = 5;
|
||||
|
||||
public ushort TimeLimitHours { get; init; } = 24;
|
||||
|
||||
public bool ArchiveEnabled { get; init; } = true;
|
||||
|
||||
public ushort ArchiveRetainedCount { get; init; } = 60;
|
||||
|
||||
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
|
||||
|
||||
public bool ApplyTo(LoggingConfig existingConfig)
|
||||
{
|
||||
bool levelChanged = existingConfig.Level != Level;
|
||||
bool otherPropertiesChanged =
|
||||
existingConfig.RollingSizeMB != RollingSizeMB ||
|
||||
existingConfig.RetainedFileCount != RetainedFileCount ||
|
||||
existingConfig.TimeLimitHours != TimeLimitHours ||
|
||||
existingConfig.ArchiveEnabled != ArchiveEnabled ||
|
||||
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
|
||||
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Level = Level;
|
||||
existingConfig.RollingSizeMB = RollingSizeMB;
|
||||
existingConfig.RetainedFileCount = RetainedFileCount;
|
||||
existingConfig.TimeLimitHours = TimeLimitHours;
|
||||
existingConfig.ArchiveEnabled = ArchiveEnabled;
|
||||
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
|
||||
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
|
||||
|
||||
existingConfig.Validate();
|
||||
|
||||
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
|
||||
|
||||
return levelChanged || otherPropertiesChanged;
|
||||
}
|
||||
|
||||
public bool LevelOnlyChange { get; private set; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.General.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class GeneralConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<GeneralConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly MemoryCache _cache;
|
||||
|
||||
public GeneralConfigController(
|
||||
ILogger<GeneralConfigController> logger,
|
||||
DataContext dataContext,
|
||||
MemoryCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpGet("general")]
|
||||
public async Task<IActionResult> GetGeneralConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.GeneralConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("general")]
|
||||
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.GeneralConfigs
|
||||
.FirstAsync();
|
||||
|
||||
bool wasDryRun = config.DryRun;
|
||||
|
||||
request.ApplyTo(config, HttpContext.RequestServices, _logger);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
|
||||
|
||||
return Ok(new { Message = "General configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save General configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
|
||||
{
|
||||
if (!wasDryRun || isDryRun)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<object> keys;
|
||||
|
||||
// Remove strikes
|
||||
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
|
||||
{
|
||||
keys = _cache.Keys
|
||||
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.ToList();
|
||||
|
||||
foreach (object key in keys)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
|
||||
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
|
||||
}
|
||||
|
||||
// Remove strike cache items
|
||||
keys = _cache.Keys
|
||||
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.ToList();
|
||||
|
||||
foreach (object key in keys)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0/5 * * * * ?";
|
||||
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public bool DeleteKnownMalware { get; init; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Radarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Lidarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Readarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Whisparr { get; init; } = new();
|
||||
|
||||
public List<string> IgnoredDownloads { get; init; } = [];
|
||||
|
||||
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
|
||||
{
|
||||
config.Enabled = Enabled;
|
||||
config.CronExpression = CronExpression;
|
||||
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||
config.IgnorePrivate = IgnorePrivate;
|
||||
config.DeletePrivate = DeletePrivate;
|
||||
config.DeleteKnownMalware = DeleteKnownMalware;
|
||||
config.Sonarr = Sonarr;
|
||||
config.Radarr = Radarr;
|
||||
config.Lidarr = Lidarr;
|
||||
config.Readarr = Readarr;
|
||||
config.Whisparr = Whisparr;
|
||||
config.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class MalwareBlockerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MalwareBlockerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public MalwareBlockerConfigController(
|
||||
ILogger<MalwareBlockerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("malware_blocker")]
|
||||
public async Task<IActionResult> GetMalwareBlockerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("malware_blocker")]
|
||||
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] UpdateMalwareBlockerConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(request.CronExpression, JobType.MalwareBlocker);
|
||||
}
|
||||
|
||||
var config = await _dataContext.ContentBlockerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
request.ApplyTo(config);
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(config, JobType.MalwareBlocker);
|
||||
|
||||
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||
jobType.ToString(), config.CronExpression);
|
||||
|
||||
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||
await _jobManagementService.StopJob(jobType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateNotifiarrProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public abstract record CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
|
||||
public bool OnFailedImportStrike { get; init; }
|
||||
|
||||
public bool OnStalledStrike { get; init; }
|
||||
|
||||
public bool OnSlowStrike { get; init; }
|
||||
|
||||
public bool OnQueueItemDeleted { get; init; }
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateNtfyProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestAppriseProviderRequest
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestNotifiarrProviderRequest
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestNtfyProviderRequest
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestPushoverProviderRequest
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public string Tags { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateNotifiarrProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public abstract record UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public bool OnFailedImportStrike { get; init; }
|
||||
|
||||
public bool OnStalledStrike { get; init; }
|
||||
|
||||
public bool OnSlowStrike { get; init; }
|
||||
|
||||
public bool OnQueueItemDeleted { get; init; }
|
||||
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateNtfyProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Topics { get; init; } = [];
|
||||
|
||||
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdatePushoverProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ApiToken { get; init; } = string.Empty;
|
||||
|
||||
public string UserKey { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Devices { get; init; } = [];
|
||||
|
||||
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||
|
||||
public string? Sound { get; init; }
|
||||
|
||||
public int? Retry { get; init; }
|
||||
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
|
||||
public sealed record NotificationProviderResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public NotificationProviderType Type { get; init; }
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public NotificationEventFlags Events { get; init; } = new();
|
||||
|
||||
public object Configuration { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
|
||||
public sealed record NotificationProvidersResponse
|
||||
{
|
||||
public List<NotificationProviderResponse> Providers { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration/notification_providers")]
|
||||
public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<NotificationProvidersController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly INotificationConfigurationService _notificationConfigurationService;
|
||||
private readonly NotificationService _notificationService;
|
||||
|
||||
public NotificationProvidersController(
|
||||
ILogger<NotificationProvidersController> logger,
|
||||
DataContext dataContext,
|
||||
INotificationConfigurationService notificationConfigurationService,
|
||||
NotificationService notificationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_notificationConfigurationService = notificationConfigurationService;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetNotificationProviders()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var providers = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var providerDtos = providers
|
||||
.Select(p => new NotificationProviderResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Type = p.Type,
|
||||
IsEnabled = p.IsEnabled,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = p.OnFailedImportStrike,
|
||||
OnStalledStrike = p.OnStalledStrike,
|
||||
OnSlowStrike = p.OnSlowStrike,
|
||||
OnQueueItemDeleted = p.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = p.OnDownloadCleaned,
|
||||
OnCategoryChanged = p.OnCategoryChanged
|
||||
},
|
||||
Configuration = p.Type switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
.OrderBy(x => x.Type.ToString())
|
||||
.ThenBy(x => x.Name)
|
||||
.ToList();
|
||||
|
||||
var response = new NotificationProvidersResponse { Providers = providerDtos };
|
||||
return Ok(response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("notifiarr")]
|
||||
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = newProvider.ApiKey,
|
||||
ChannelId = newProvider.ChannelId
|
||||
};
|
||||
notifiarrConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
NotifiarrConfiguration = notifiarrConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Notifiarr provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("apprise")]
|
||||
public async Task<IActionResult> CreateAppriseProvider([FromBody] CreateAppriseProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Url = newProvider.Url,
|
||||
Key = newProvider.Key,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
AppriseConfiguration = appriseConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Apprise provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ntfy")]
|
||||
public async Task<IActionResult> CreateNtfyProvider([FromBody] CreateNtfyProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
Topics = newProvider.Topics,
|
||||
AuthenticationType = newProvider.AuthenticationType,
|
||||
Username = newProvider.Username,
|
||||
Password = newProvider.Password,
|
||||
AccessToken = newProvider.AccessToken,
|
||||
Priority = newProvider.Priority,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
ntfyConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
NtfyConfiguration = ntfyConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Ntfy provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("notifiarr/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Notifiarr);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Notifiarr provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = updatedProvider.ApiKey,
|
||||
ChannelId = updatedProvider.ChannelId
|
||||
};
|
||||
|
||||
if (existingProvider.NotifiarrConfiguration != null)
|
||||
{
|
||||
notifiarrConfig = notifiarrConfig with { Id = existingProvider.NotifiarrConfiguration.Id };
|
||||
}
|
||||
notifiarrConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
NotifiarrConfiguration = notifiarrConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("apprise/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateAppriseProvider(Guid id, [FromBody] UpdateAppriseProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Apprise);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Apprise provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Url = updatedProvider.Url,
|
||||
Key = updatedProvider.Key,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.AppriseConfiguration != null)
|
||||
{
|
||||
appriseConfig = appriseConfig with { Id = existingProvider.AppriseConfiguration.Id };
|
||||
}
|
||||
appriseConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
AppriseConfiguration = appriseConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("ntfy/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateNtfyProvider(Guid id, [FromBody] UpdateNtfyProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Ntfy);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Ntfy provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
Topics = updatedProvider.Topics,
|
||||
AuthenticationType = updatedProvider.AuthenticationType,
|
||||
Username = updatedProvider.Username,
|
||||
Password = updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken,
|
||||
Priority = updatedProvider.Priority,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.NtfyConfiguration != null)
|
||||
{
|
||||
ntfyConfig = ntfyConfig with { Id = existingProvider.NtfyConfiguration.Id };
|
||||
}
|
||||
ntfyConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
NtfyConfiguration = ntfyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.NotifiarrConfiguration)
|
||||
.Include(p => p.AppriseConfiguration)
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Notification provider with ID {id} not found");
|
||||
}
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
|
||||
existingProvider.Name, existingProvider.Id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("notifiarr/test")]
|
||||
public async Task<IActionResult> TestNotifiarrProvider([FromBody] TestNotifiarrProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = testRequest.ApiKey,
|
||||
ChannelId = testRequest.ChannelId
|
||||
};
|
||||
notifiarrConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Notifiarr,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = notifiarrConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Notifiarr provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("apprise/test")]
|
||||
public async Task<IActionResult> TestAppriseProvider([FromBody] TestAppriseProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Url = testRequest.Url,
|
||||
Key = testRequest.Key,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Apprise,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = appriseConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Apprise provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ntfy/test")]
|
||||
public async Task<IActionResult> TestNtfyProvider([FromBody] TestNtfyProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
Topics = testRequest.Topics,
|
||||
AuthenticationType = testRequest.AuthenticationType,
|
||||
Username = testRequest.Username,
|
||||
Password = testRequest.Password,
|
||||
AccessToken = testRequest.AccessToken,
|
||||
Priority = testRequest.Priority,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
ntfyConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Ntfy,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = ntfyConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Ntfy provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationProviderResponse MapProvider(NotificationConfig provider)
|
||||
{
|
||||
return new NotificationProviderResponse
|
||||
{
|
||||
Id = provider.Id,
|
||||
Name = provider.Name,
|
||||
Type = provider.Type,
|
||||
IsEnabled = provider.IsEnabled,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = provider.OnFailedImportStrike,
|
||||
OnStalledStrike = provider.OnStalledStrike,
|
||||
OnSlowStrike = provider.OnSlowStrike,
|
||||
OnQueueItemDeleted = provider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = provider.OnDownloadCleaned,
|
||||
OnCategoryChanged = provider.OnCategoryChanged
|
||||
},
|
||||
Configuration = provider.Type switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
|
||||
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
|
||||
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
|
||||
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("pushover")]
|
||||
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = newProvider.ApiToken,
|
||||
UserKey = newProvider.UserKey,
|
||||
Devices = newProvider.Devices,
|
||||
Priority = newProvider.Priority,
|
||||
Sound = newProvider.Sound,
|
||||
Retry = newProvider.Retry,
|
||||
Expire = newProvider.Expire,
|
||||
Tags = newProvider.Tags
|
||||
};
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
PushoverConfiguration = pushoverConfig
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Add(provider);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(provider);
|
||||
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Pushover provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("pushover/{id:guid}")]
|
||||
public async Task<IActionResult> UpdatePushoverProvider(Guid id, [FromBody] UpdatePushoverProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Pushover);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Pushover provider with ID {id} not found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
|
||||
{
|
||||
return BadRequest("Provider name is required");
|
||||
}
|
||||
|
||||
var duplicateConfig = await _dataContext.NotificationConfigs
|
||||
.Where(x => x.Id != id)
|
||||
.Where(x => x.Name == updatedProvider.Name)
|
||||
.CountAsync();
|
||||
if (duplicateConfig > 0)
|
||||
{
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey,
|
||||
Devices = updatedProvider.Devices,
|
||||
Priority = updatedProvider.Priority,
|
||||
Sound = updatedProvider.Sound,
|
||||
Retry = updatedProvider.Retry,
|
||||
Expire = updatedProvider.Expire,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
|
||||
if (existingProvider.PushoverConfiguration != null)
|
||||
{
|
||||
pushoverConfig = pushoverConfig with { Id = existingProvider.PushoverConfiguration.Id };
|
||||
}
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var newProvider = existingProvider with
|
||||
{
|
||||
Name = updatedProvider.Name,
|
||||
IsEnabled = updatedProvider.IsEnabled,
|
||||
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = updatedProvider.OnStalledStrike,
|
||||
OnSlowStrike = updatedProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
PushoverConfiguration = pushoverConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dataContext.NotificationConfigs.Remove(existingProvider);
|
||||
_dataContext.NotificationConfigs.Add(newProvider);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await _notificationConfigurationService.InvalidateCacheAsync();
|
||||
|
||||
var providerDto = MapProvider(newProvider);
|
||||
return Ok(providerDto);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("pushover/test")]
|
||||
public async Task<IActionResult> TestPushoverProvider([FromBody] TestPushoverProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = testRequest.ApiToken,
|
||||
UserKey = testRequest.UserKey,
|
||||
Devices = testRequest.Devices,
|
||||
Priority = testRequest.Priority,
|
||||
Sound = testRequest.Sound,
|
||||
Retry = testRequest.Retry,
|
||||
Expire = testRequest.Expire,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
pushoverConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Pushover,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = pushoverConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Pushover provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public abstract record QueueRuleDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[Range(3, int.MaxValue, ErrorMessage = "Max strikes must be at least 3")]
|
||||
public int MaxStrikes { get; set; } = 3;
|
||||
|
||||
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Minimum completion percentage must be between 0 and 100")]
|
||||
public ushort MinCompletionPercentage { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Maximum completion percentage must be between 0 and 100")]
|
||||
public ushort MaxCompletionPercentage { get; set; }
|
||||
|
||||
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record SlowRuleDto : QueueRuleDto
|
||||
{
|
||||
public bool ResetStrikesOnProgress { get; set; } = true;
|
||||
|
||||
public string MinSpeed { get; set; } = string.Empty;
|
||||
|
||||
[Range(0, double.MaxValue, ErrorMessage = "Maximum time cannot be negative")]
|
||||
public double MaxTimeHours { get; set; } = 0;
|
||||
|
||||
public string? IgnoreAboveSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record StallRuleDto : QueueRuleDto
|
||||
{
|
||||
public bool ResetStrikesOnProgress { get; set; } = true;
|
||||
|
||||
public string? MinimumProgress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0 0/5 * * * ?";
|
||||
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
|
||||
public FailedImportConfig FailedImport { get; init; } = new();
|
||||
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
public sealed class QueueCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueCleanerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public QueueCleanerConfigController(
|
||||
ILogger<QueueCleanerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("queue_cleaner")]
|
||||
public async Task<IActionResult> GetQueueCleanerConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.QueueCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("queue_cleaner")]
|
||||
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] UpdateQueueCleanerConfigRequest newConfigDto)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
var oldConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
oldConfig.Enabled = newConfigDto.Enabled;
|
||||
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.FailedImport = newConfigDto.FailedImport;
|
||||
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(oldConfig, JobType.QueueCleaner);
|
||||
|
||||
return Ok(new { Message = "QueueCleaner configuration updated successfully" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save QueueCleaner configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||
jobType.ToString(), config.CronExpression);
|
||||
|
||||
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||
await _jobManagementService.StopJob(jobType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/queue-rules")]
|
||||
public class QueueRulesController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueRulesController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IRuleIntervalValidator _ruleIntervalValidator;
|
||||
|
||||
public QueueRulesController(
|
||||
ILogger<QueueRulesController> logger,
|
||||
DataContext dataContext,
|
||||
IRuleIntervalValidator ruleIntervalValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_ruleIntervalValidator = ruleIntervalValidator;
|
||||
}
|
||||
|
||||
[HttpGet("stall")]
|
||||
public async Task<IActionResult> GetStallRules()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var rules = await _dataContext.StallRules
|
||||
.OrderBy(r => r.MinCompletionPercentage)
|
||||
.ThenBy(r => r.Name)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(rules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve stall rules");
|
||||
return StatusCode(500, new { Message = "Failed to retrieve stall rules", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("stall")]
|
||||
public async Task<IActionResult> CreateStallRule([FromBody] StallRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
var existingRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (existingRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A stall rule with this name already exists" });
|
||||
}
|
||||
|
||||
var rule = new StallRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id,
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.StallRules.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(rule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
rule.Validate();
|
||||
|
||||
_dataContext.StallRules.Add(rule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created stall rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
|
||||
|
||||
return CreatedAtAction(nameof(GetStallRules), new { id = rule.Id }, rule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for stall rule creation: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create stall rule: {RuleName}", ruleDto.Name);
|
||||
return StatusCode(500, new { Message = "Failed to create stall rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("stall/{id}")]
|
||||
public async Task<IActionResult> UpdateStallRule(Guid id, [FromBody] StallRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
var duplicateRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (duplicateRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A stall rule with this name already exists" });
|
||||
}
|
||||
|
||||
var updatedRule = existingRule with
|
||||
{
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.StallRules
|
||||
.Where(r => r.Id != id)
|
||||
.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(updatedRule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
updatedRule.Validate();
|
||||
|
||||
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated stall rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
|
||||
|
||||
return Ok(updatedRule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for stall rule update: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update stall rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to update stall rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("stall/{id}")]
|
||||
public async Task<IActionResult> DeleteStallRule(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.StallRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
_dataContext.StallRules.Remove(existingRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted stall rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete stall rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to delete stall rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("slow")]
|
||||
public async Task<IActionResult> GetSlowRules()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var rules = await _dataContext.SlowRules
|
||||
.OrderBy(r => r.MinCompletionPercentage)
|
||||
.ThenBy(r => r.Name)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(rules);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve slow rules");
|
||||
return StatusCode(500, new { Message = "Failed to retrieve slow rules", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("slow")]
|
||||
public async Task<IActionResult> CreateSlowRule([FromBody] SlowRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
var existingRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (existingRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A slow rule with this name already exists" });
|
||||
}
|
||||
|
||||
var rule = new SlowRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QueueCleanerConfigId = queueCleanerConfig.Id,
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.SlowRules.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(rule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
rule.Validate();
|
||||
|
||||
_dataContext.SlowRules.Add(rule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created slow rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
|
||||
|
||||
return CreatedAtAction(nameof(GetSlowRules), new { id = rule.Id }, rule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for slow rule creation: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create slow rule: {RuleName}", ruleDto.Name);
|
||||
return StatusCode(500, new { Message = "Failed to create slow rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("slow/{id}")]
|
||||
public async Task<IActionResult> UpdateSlowRule(Guid id, [FromBody] SlowRuleDto ruleDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
var duplicateRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
|
||||
|
||||
if (duplicateRule != null)
|
||||
{
|
||||
return BadRequest(new { Message = "A slow rule with this name already exists" });
|
||||
}
|
||||
|
||||
var updatedRule = existingRule with
|
||||
{
|
||||
Name = ruleDto.Name.Trim(),
|
||||
Enabled = ruleDto.Enabled,
|
||||
MaxStrikes = ruleDto.MaxStrikes,
|
||||
PrivacyType = ruleDto.PrivacyType,
|
||||
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
|
||||
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
|
||||
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
|
||||
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
|
||||
MaxTimeHours = ruleDto.MaxTimeHours,
|
||||
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
|
||||
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
|
||||
};
|
||||
|
||||
var existingRules = await _dataContext.SlowRules
|
||||
.Where(r => r.Id != id)
|
||||
.ToListAsync();
|
||||
|
||||
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(updatedRule, existingRules);
|
||||
if (!intervalValidationResult.IsValid)
|
||||
{
|
||||
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
|
||||
}
|
||||
|
||||
updatedRule.Validate();
|
||||
|
||||
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated slow rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
|
||||
|
||||
return Ok(updatedRule);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
_logger.LogWarning("Validation failed for slow rule update: {Message}", ex.Message);
|
||||
return BadRequest(new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update slow rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to update slow rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("slow/{id}")]
|
||||
public async Task<IActionResult> DeleteSlowRule(Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingRule = await _dataContext.SlowRules
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
|
||||
}
|
||||
|
||||
_dataContext.SlowRules.Remove(existingRule);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted slow rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete slow rule with ID: {RuleId}", id);
|
||||
return StatusCode(500, new { Message = "Failed to delete slow rule", Error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -6,33 +9,61 @@ namespace Cleanuparr.Api;
|
||||
|
||||
public static class HostExtensions
|
||||
{
|
||||
public static async Task<IHost> Init(this WebApplication app)
|
||||
public static IHost Init(this WebApplication app)
|
||||
{
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
AppStatusSnapshot statusSnapshot = app.Services.GetRequiredService<AppStatusSnapshot>();
|
||||
|
||||
Version? version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
string? formattedVersion = FormatVersion(version);
|
||||
|
||||
if (statusSnapshot.UpdateCurrentVersion(formattedVersion, out _))
|
||||
{
|
||||
logger.LogDebug("App status current version set to {Version}", formattedVersion);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
version is null
|
||||
? "Cleanuparr version not detected"
|
||||
: $"Cleanuparr v{version.Major}.{version.Minor}.{version.Build}"
|
||||
: $"Cleanuparr {formattedVersion}"
|
||||
);
|
||||
|
||||
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
|
||||
|
||||
// Apply db migrations
|
||||
var eventsContext = app.Services.GetRequiredService<EventsContext>();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? FormatVersion(Version? version)
|
||||
{
|
||||
if (version is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (version.Build >= 0)
|
||||
{
|
||||
return $"v{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
return $"v{version.Major}.{version.Minor}";
|
||||
}
|
||||
|
||||
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
|
||||
{
|
||||
// Apply events db migrations
|
||||
await using var eventsContext = EventsContext.CreateStaticInstance();
|
||||
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await eventsContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
var configContext = app.Services.GetRequiredService<DataContext>();
|
||||
// Apply data db migrations
|
||||
await using var configContext = DataContext.CreateStaticInstance();
|
||||
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
|
||||
{
|
||||
await configContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
return app;
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
@@ -22,18 +20,18 @@ namespace Cleanuparr.Api.Jobs;
|
||||
public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<BackgroundJobManager> _logger;
|
||||
private IScheduler? _scheduler;
|
||||
|
||||
public BackgroundJobManager(
|
||||
ISchedulerFactory schedulerFactory,
|
||||
DataContext dataContext,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BackgroundJobManager> logger
|
||||
)
|
||||
{
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_dataContext = dataContext;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -45,12 +43,12 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting BackgroundJobManager");
|
||||
_logger.LogDebug("Starting BackgroundJobManager");
|
||||
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
await InitializeJobsFromConfiguration(cancellationToken);
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager started");
|
||||
_logger.LogDebug("BackgroundJobManager started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -64,15 +62,15 @@ public class BackgroundJobManager : IHostedService
|
||||
/// </summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping BackgroundJobManager");
|
||||
_logger.LogDebug("Stopping BackgroundJobManager");
|
||||
|
||||
if (_scheduler != null)
|
||||
{
|
||||
// Don't shutdown the scheduler as it's managed by QuartzHostedService
|
||||
// Don't shut down the scheduler as it's managed by QuartzHostedService
|
||||
await _scheduler.Standby(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager stopped");
|
||||
_logger.LogDebug("BackgroundJobManager stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,21 +84,28 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
// Get configurations from db
|
||||
QueueCleanerConfig queueCleanerConfig = await _dataContext.QueueCleanerConfigs
|
||||
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
ContentBlockerConfig contentBlockerConfig = await _dataContext.ContentBlockerConfigs
|
||||
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
DownloadCleanerConfig downloadCleanerConfig = await _dataContext.DownloadCleanerConfigs
|
||||
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -116,24 +121,24 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterContentBlockerJob(
|
||||
public async Task RegisterMalwareBlockerJob(
|
||||
ContentBlockerConfig config,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
|
||||
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +153,21 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +175,9 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
private async Task AddTriggersForJob<T>(
|
||||
IJobConfig config,
|
||||
string cronExpression,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -186,7 +204,7 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
@@ -207,16 +225,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Schedule the main trigger
|
||||
await _scheduler.ScheduleJob(trigger, cancellationToken);
|
||||
|
||||
// Trigger immediate execution for startup using a one-time trigger
|
||||
var startupTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
|
||||
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression}",
|
||||
typeName, cronExpression);
|
||||
}
|
||||
|
||||
@@ -224,7 +233,7 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -250,6 +259,6 @@ public class BackgroundJobManager : IHostedService
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Registered job {name} without trigger", typeName);
|
||||
_logger.LogDebug("Registered job {name} without trigger", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Quartz;
|
||||
using Serilog.Context;
|
||||
|
||||
@@ -9,12 +13,12 @@ public sealed class GenericJob<T> : IJob
|
||||
where T : IHandler
|
||||
{
|
||||
private readonly ILogger<GenericJob<T>> _logger;
|
||||
private readonly T _handler;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
@@ -23,11 +27,40 @@ public sealed class GenericJob<T> : IJob
|
||||
|
||||
try
|
||||
{
|
||||
await _handler.ExecuteAsync();
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
|
||||
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
await handler.ExecuteAsync();
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{name} failed", typeof(T).Name);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
|
||||
{
|
||||
try
|
||||
{
|
||||
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
|
||||
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
|
||||
|
||||
if (isFinished)
|
||||
{
|
||||
jobInfo.Status = "Scheduled";
|
||||
}
|
||||
|
||||
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to broadcast job status update");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using CreateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest instead.")]
|
||||
public sealed record CreateAppriseProviderDto : CreateAppriseProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using CreateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest instead.")]
|
||||
public sealed record CreateNotifiarrProviderDto : CreateNotifiarrProviderRequest;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using CreateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest instead.")]
|
||||
public sealed record CreateNtfyProviderDto : CreateNtfyProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using TestAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest instead.")]
|
||||
public sealed record TestAppriseProviderDto : TestAppriseProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using TestNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest instead.")]
|
||||
public sealed record TestNotifiarrProviderDto : TestNotifiarrProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using TestNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest instead.")]
|
||||
public sealed record TestNtfyProviderDto : TestNtfyProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using UpdateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest instead.")]
|
||||
public sealed record UpdateAppriseProviderDto : UpdateAppriseProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using UpdateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest instead.")]
|
||||
public sealed record UpdateNotifiarrProviderDto : UpdateNotifiarrProviderRequest;
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Cleanuparr.Api.Models.NotificationProviders;
|
||||
|
||||
using UpdateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest;
|
||||
|
||||
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest instead.")]
|
||||
public sealed record UpdateNtfyProviderDto : UpdateNtfyProviderRequest;
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -0,0 +1 @@
|
||||
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests
|
||||
@@ -1,53 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Models;
|
||||
|
||||
public class UpdateDownloadCleanerConfigDto
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string CronExpression { get; set; } = "0 0 * * * ?";
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule
|
||||
/// </summary>
|
||||
public bool UseAdvancedScheduling { get; set; }
|
||||
|
||||
public List<CleanCategoryDto> Categories { get; set; } = [];
|
||||
|
||||
public bool DeletePrivate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether unlinked download handling is enabled
|
||||
/// </summary>
|
||||
public bool UnlinkedEnabled { get; set; } = false;
|
||||
|
||||
public string UnlinkedTargetCategory { get; set; } = "cleanuparr-unlinked";
|
||||
|
||||
public bool UnlinkedUseTag { get; set; }
|
||||
|
||||
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
|
||||
|
||||
public List<string> UnlinkedCategories { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CleanCategoryDto
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Max ratio before removing a download.
|
||||
/// </summary>
|
||||
public double MaxRatio { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||
/// </summary>
|
||||
public double MinSeedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hours to seed before removing a download.
|
||||
/// </summary>
|
||||
public double MaxSeedTime { get; set; } = -1;
|
||||
}
|
||||
@@ -2,14 +2,18 @@ using System.Runtime.InteropServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api;
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
await builder.InitAsync();
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
// Fix paths for single-file deployment on macOS
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
@@ -46,6 +50,7 @@ builder.Services.AddResponseCompression(options =>
|
||||
// Configure JSON options to serialize enums as strings
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
@@ -68,14 +73,6 @@ builder.Services.AddCors(options =>
|
||||
});
|
||||
});
|
||||
|
||||
// Register services needed for logging first
|
||||
builder.Services
|
||||
.AddTransient<LoggingConfigManager>()
|
||||
.AddSingleton<SignalRLogSink>();
|
||||
|
||||
// Add logging with proper service provider
|
||||
builder.Logging.AddLogging();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
builder.Host.UseWindowsService(options =>
|
||||
@@ -130,24 +127,11 @@ if (basePath is not null)
|
||||
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
|
||||
|
||||
// Initialize the host
|
||||
await app.Init();
|
||||
app.Init();
|
||||
|
||||
// Get LoggingConfigManager (will be created if not already registered)
|
||||
var configManager = app.Services.GetRequiredService<LoggingConfigManager>();
|
||||
|
||||
// Get the dynamic level switch for controlling log levels
|
||||
var levelSwitch = configManager.GetLevelSwitch();
|
||||
|
||||
// Get the SignalRLogSink instance
|
||||
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
|
||||
|
||||
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
|
||||
logConfig.MinimumLevel.ControlledBy(levelSwitch);
|
||||
|
||||
// Add to Serilog pipeline
|
||||
logConfig.WriteTo.Sink(signalRSink);
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
// Configure the app hub for SignalR
|
||||
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
|
||||
SignalRLogSink.Instance.SetAppHubContext(appHub);
|
||||
|
||||
// Configure health check endpoints before the API configuration
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
@@ -164,4 +148,9 @@ app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
|
||||
app.ConfigureApi();
|
||||
|
||||
await app.RunAsync();
|
||||
await app.RunAsync();
|
||||
|
||||
await Log.CloseAndFlushAsync();
|
||||
|
||||
// Make Program class accessible for testing
|
||||
public partial class Program { }
|
||||
3
code/backend/Cleanuparr.Api/Properties/AssemblyInfo.cs
Normal file
3
code/backend/Cleanuparr.Api/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Cleanuparr.Api.Tests")]
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Domain\Cleanuparr.Domain.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Persistence\Cleanuparr.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Application.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LogContext = Serilog.Context.LogContext;
|
||||
|
||||
namespace Cleanuparr.Application.Features.DownloadCleaner;
|
||||
|
||||
public sealed class DownloadCleaner : GenericHandler
|
||||
{
|
||||
private readonly HashSet<string> _excludedHashes = [];
|
||||
|
||||
public DownloadCleaner(
|
||||
ILogger<DownloadCleaner> logger,
|
||||
DataContext dataContext,
|
||||
IMemoryCache cache,
|
||||
IBus messageBus,
|
||||
ArrClientFactory arrClientFactory,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
EventPublisher eventPublisher
|
||||
) : base(
|
||||
logger, dataContext, cache, messageBus,
|
||||
arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task ExecuteInternalAsync()
|
||||
{
|
||||
var downloadServices = await GetInitializedDownloadServicesAsync();
|
||||
|
||||
if (downloadServices.Count is 0)
|
||||
{
|
||||
_logger.LogWarning("Processing skipped because no download clients are configured");
|
||||
return;
|
||||
}
|
||||
|
||||
var config = ContextProvider.Get<DownloadCleanerConfig>();
|
||||
|
||||
bool isUnlinkedEnabled = config.UnlinkedEnabled && !string.IsNullOrEmpty(config.UnlinkedTargetCategory) && config.UnlinkedCategories.Count > 0;
|
||||
bool isCleaningEnabled = config.Categories.Count > 0;
|
||||
|
||||
if (!isUnlinkedEnabled && !isCleaningEnabled)
|
||||
{
|
||||
_logger.LogWarning("{name} is not configured properly", nameof(DownloadCleaner));
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
|
||||
// Process each client separately
|
||||
var allDownloads = new List<object>();
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
await downloadService.LoginAsync();
|
||||
var clientDownloads = await downloadService.GetSeedingDownloads();
|
||||
if (clientDownloads?.Count > 0)
|
||||
{
|
||||
allDownloads.AddRange(clientDownloads);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get seeding downloads from download client");
|
||||
}
|
||||
}
|
||||
|
||||
if (allDownloads.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("no seeding downloads found");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("found {count} seeding downloads", allDownloads.Count);
|
||||
|
||||
// List<object>? downloadsToChangeCategory = null;
|
||||
List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = [];
|
||||
|
||||
if (isUnlinkedEnabled)
|
||||
{
|
||||
// Create category for all clients
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
await downloadService.CreateCategoryAsync(config.UnlinkedTargetCategory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create category for download client");
|
||||
}
|
||||
}
|
||||
|
||||
// Get downloads to change category
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clientDownloads = downloadService.FilterDownloadsToChangeCategoryAsync(allDownloads, config.UnlinkedCategories);
|
||||
if (clientDownloads?.Count > 0)
|
||||
{
|
||||
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to filter downloads for category change");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the downloads to appear in the arr queue
|
||||
await Task.Delay(10 * 1000);
|
||||
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
|
||||
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process each client with its own filtered downloads
|
||||
foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads)
|
||||
{
|
||||
try
|
||||
{
|
||||
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to change category for download client {clientName}", downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Finished changing category");
|
||||
}
|
||||
|
||||
if (config.Categories.Count is 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get downloads to clean
|
||||
downloadServiceWithDownloads = [];
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clientDownloads = downloadService.FilterDownloadsToBeCleanedAsync(allDownloads, config.Categories);
|
||||
if (clientDownloads?.Count > 0)
|
||||
{
|
||||
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to filter downloads for cleaning for download client {clientName}", downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// release unused objects
|
||||
allDownloads = null;
|
||||
|
||||
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process cleaning for each client
|
||||
foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads)
|
||||
{
|
||||
try
|
||||
{
|
||||
await downloadService.CleanDownloadsAsync(downloadsToClean, config.Categories, _excludedHashes, ignoredDownloads);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clean downloads for download client {clientName}", downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("finished cleaning downloads");
|
||||
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
downloadService.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.Where(x => !string.IsNullOrEmpty(x.DownloadId))
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
foreach (QueueRecord record in groups.Select(group => group.First()))
|
||||
{
|
||||
_excludedHashes.Add(record.DownloadId.ToLowerInvariant());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
|
||||
namespace Cleanuparr.Application.Features.DownloadClient.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for creating a new download client (without ID)
|
||||
/// </summary>
|
||||
public sealed record CreateDownloadClientDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this client is enabled
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Friendly name for this client
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type name of download client
|
||||
/// </summary>
|
||||
public required DownloadClientTypeName TypeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of download client
|
||||
/// </summary>
|
||||
public required DownloadClientType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host address for the download client
|
||||
/// </summary>
|
||||
public Uri? Host { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Username for authentication
|
||||
/// </summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for authentication
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The base URL path component, used by clients like Transmission and Deluge
|
||||
/// </summary>
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new ValidationException("Client name cannot be empty");
|
||||
}
|
||||
|
||||
if (Host is null)
|
||||
{
|
||||
throw new ValidationException("Host cannot be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user