Compare commits

..

92 Commits
dev ... v2.3.3

Author SHA1 Message Date
Flaminel
16e823b8d3 Add status json to Cloudflare pages (#323) 2025-09-29 21:43:15 +03:00
Flaminel
f2f11e3472 Fix UI caching for Download Cleaner page (#316) 2025-09-29 21:37:25 +03:00
Flaminel
a3549c80a9 Fix icons paths (#322) 2025-09-29 21:36:59 +03:00
Flaminel
2b9c347ed6 Fix docs build workflow (#315) 2025-09-25 12:23:20 +03:00
Flaminel
98ccee866d Fix sidebar styling (#313) 2025-09-25 12:06:46 +03:00
Flaminel
911849c6dd Fix blacklist synchronizer docs (#307) 2025-09-16 23:30:38 +03:00
Flaminel
cce3bb2c4a Add ntfy support (#300) 2025-09-15 22:08:48 +03:00
Flaminel
bcc117cd0d Fix slow strikes not being reset (#305) 2025-09-15 22:03:18 +03:00
Flaminel
8e20a68ae2 Improve frontend layout (#299) 2025-09-15 22:03:03 +03:00
Flaminel
736c146f25 Add ignored downloads setting per job (#301) 2025-09-15 22:02:03 +03:00
Flaminel
6398ef1cc6 Add option to inject blacklist into qBittorrent (#304) 2025-09-15 21:59:49 +03:00
Flaminel
83e6a289be Change docs build triggers (#303) 2025-09-15 20:55:35 +03:00
Julien Virey
5662118b01 Add documentation about archlinux package (#296) 2025-09-06 01:30:06 +03:00
Flaminel
22dfc7b40d Fix blocklist provider reporting wrong number of loaded blocklists (#293) 2025-09-04 22:14:46 +03:00
Flaminel
a51e387453 Fix log level change not taking effect (#292) 2025-09-04 22:12:57 +03:00
Flaminel
c7d2ec7311 Fix notification provider update (#291) 2025-09-03 23:48:02 +03:00
Flaminel
bb9ac5b67b Fix notifications migration when no event type is enabled (#290) 2025-09-03 21:12:55 +03:00
Flaminel
f93494adb2 Rework notifications system (#284) 2025-09-02 23:18:22 +03:00
Flaminel
7201520411 Add configurable log retention (#279) 2025-09-02 00:17:16 +03:00
Flaminel
2a1e65e1af Make sidebar scrollable (#285) 2025-09-02 00:16:38 +03:00
Flaminel
da318c3339 Fix HTTPS schema for Cloudflare pages links (#286) 2025-09-02 00:16:27 +03:00
Flaminel
7149b6243f Add .sql to the blacklist (#287) 2025-09-02 00:16:12 +03:00
Flaminel
11f5a28c04 Improve download client health checks (#288) 2025-09-02 00:15:09 +03:00
Flaminel
9cc36c7a50 Add qBittorrent basic auth support (#246) 2025-08-11 10:52:44 +03:00
Flaminel
861c135cc6 fixed Malware Blocker docs path 2025-08-07 11:55:46 +03:00
Flaminel
3b0275c411 Finish rebranding Content Blocker to Malware Blocker (#271) 2025-08-06 22:55:39 +03:00
Flaminel
cad1b51202 Improve logs and events ordering to be descending from the top (#270) 2025-08-06 22:51:20 +03:00
Flaminel
f50acd29f4 Disable MassTransit telemetry (#268) 2025-08-06 22:50:48 +03:00
LucasFA
af11d595d8 Fix detailed installation docs (#260)
https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed
2025-08-06 22:49:14 +03:00
Flaminel
44994d5b21 Fix Notifiarr channel id input (#267) 2025-08-04 22:07:33 +03:00
Flaminel
592fd2d846 Fix Malware Blocker renaming issue (#259) 2025-08-02 15:54:26 +03:00
Flaminel
e96be1fca2 Small general fixes (#257)
* renamed ContentBlocker into MalwareBlocker in the logs

* fixed "Delete Private" input description
2025-08-02 11:36:47 +03:00
Flaminel
ee44e2b5ac Rework sidebar navigation (#255) 2025-08-02 05:31:25 +03:00
Flaminel
323bfc4d2e added major and minor tags for Docker images 2025-08-01 19:51:10 +03:00
Flaminel
dca45585ca General frontend improvements (#252) 2025-08-01 19:45:01 +03:00
Flaminel
8b5918d221 Improve malware detection for known malware (#251) 2025-08-01 19:33:35 +03:00
Flaminel
9c227c1f59 add Cloudflare static assets 2025-08-01 18:37:45 +03:00
Flaminel
2ad4499a6f Fix DownloadCleaner failing when using multiple download clients (#248) 2025-07-31 22:20:01 +03:00
Flaminel
33a5bf9ab3 Add uTorrent support (#240) 2025-07-28 23:09:19 +03:00
Flaminel
de06d1c2d3 Fix download client type being sent as number instead of string (#245) 2025-07-27 14:23:48 +03:00
Flaminel
72855bc030 small fix on how_it_works page of the docs 2025-07-24 18:41:05 +03:00
eatsleepcoderepeat-gl
b185ea6899 Added new whitelist which includes subtitles (#243) 2025-07-24 12:50:03 +03:00
Flaminel
1e0127e97e Add more states to be picked up by Download Cleaner (#242) 2025-07-23 23:54:20 +03:00
Flaminel
5bdbc98d68 fixed Docker image path in docs 2025-07-23 11:39:50 +03:00
Flaminel
e1aeb3da31 Try #1 to fix memory leak (#241) 2025-07-22 12:24:38 +03:00
Flaminel
283b09e8f1 fixed release name 2025-07-22 12:03:23 +03:00
Flaminel
b03c96249b Improve torrent protocol detection (#235) 2025-07-07 20:42:59 +03:00
Flaminel
2971445090 Add handling type of malware when containing thepirateheaven.org file (#232) 2025-07-07 14:29:39 +03:00
Flaminel
55c23419cd Improve download removal to be separate from download search (#233) 2025-07-07 14:28:34 +03:00
Flaminel
c4b9d9503a Add more logs for debug (#201) 2025-07-07 14:28:15 +03:00
Flaminel
823b73d9f0 Fix arr max strikes not being used instead of global setting (#231) 2025-07-04 21:16:02 +03:00
Flaminel
31632d25a4 Add Whisparr support (#215) 2025-07-04 21:15:35 +03:00
Flaminel
c59951a39c Add Progressive Web App (PWA) support (#228) 2025-07-04 21:15:14 +03:00
Flaminel
d9140d7b5b Add support for Apprise tags (#229) 2025-07-04 21:14:40 +03:00
Flaminel
90865a73b5 Add failed import messages to logs (#230) 2025-07-04 21:14:27 +03:00
Flaminel
cc45233223 Add support for basic auth for Apprise (#221) 2025-07-03 12:43:43 +03:00
Flaminel
5d12d601ae fixed repo links in the docs 2025-07-01 22:02:14 +03:00
Flaminel
88f40438af Fix validations and increased strikes limits (#212) 2025-07-01 13:18:50 +03:00
Flaminel
0a9ec06841 removed forgotten release step from MacOS workflow 2025-07-01 11:05:00 +03:00
Flaminel
a0ca6ec4b8 Add curl to the Docker image (#211) 2025-07-01 10:06:22 +03:00
Flaminel
eb6cf96470 Fix cron expression inputs (#203) 2025-07-01 01:00:43 +03:00
Flaminel
2ca0616771 Add date on dashboard logs and events (#205) 2025-07-01 01:00:30 +03:00
Flaminel
bc85144e60 Improve deploy workflows (#206) 2025-07-01 01:00:16 +03:00
Flaminel
236e31c841 Add download client name on debug logs (#207) 2025-07-01 00:59:52 +03:00
Flaminel
7a15139aa6 Fix autocomplete input on mobile phones (#196) 2025-06-30 13:28:14 +03:00
Flaminel
fb6ccfd011 Add Readarr support (#191) 2025-06-29 19:54:15 +03:00
Flaminel
ef85e2b690 Fix docs broken links (#190) 2025-06-29 01:03:24 +03:00
Flaminel
bb734230aa Add health checks (#181) 2025-06-29 00:00:55 +03:00
Flaminel
aa31c31955 Remove right-side icons from settings cards (#183) 2025-06-29 00:00:25 +03:00
Flaminel
1a89822f36 Change icon direction for UI accordions (#182) 2025-06-29 00:00:11 +03:00
Flaminel
fc9e0eca36 Fix some small UI stuff (#185) 2025-06-28 23:59:49 +03:00
Flaminel
0010dcb1c6 Fix jobs not being scheduled according to the cron expression (#187) 2025-06-28 23:55:08 +03:00
Flaminel
0ab8611f29 removed Docker Hub reference 2025-06-28 11:52:34 +03:00
Flaminel
9e02408a7e Fix download cleaner categories not being fetched (#177) 2025-06-28 00:08:58 +03:00
Flaminel
1bd0db05e6 updated readme 2025-06-27 21:32:22 +03:00
Flaminel
fb438f2ca7 Fix base paths being incorrectly configured for download clients (#173) 2025-06-27 19:44:46 +03:00
Flaminel
d4de7f2ec3 Fix old category being the same as the new category when handling unlinked downloads (#172) 2025-06-27 19:12:48 +03:00
Flaminel
98ee1943f9 fixed duplicated docs description 2025-06-27 18:45:47 +03:00
Flaminel
4a57c0fba3 fixed broken docs links from README 2025-06-27 16:51:18 +03:00
Flaminel
db0698d515 fixed docs path 2025-06-27 16:33:02 +03:00
Flaminel
712cc9ff1e fixed docs broken link 2025-06-27 16:29:40 +03:00
Flaminel
501be0e4e7 triggered docs build 2025-06-27 16:26:02 +03:00
Flaminel
19b7613eea fixed release workflow 2025-06-27 16:25:53 +03:00
Flaminel
3d9cd8f6a9 fixed UI support button 2025-06-27 16:23:22 +03:00
Flaminel
c8add22d3d fixed executable build 2025-06-27 16:21:48 +03:00
Flaminel
69d8cc8fa0 fixed docker image path 2025-06-27 16:08:48 +03:00
Flaminel
8b8a4b3837 fixed docker build 2025-06-27 15:54:07 +03:00
Flaminel
c45006f219 fixed release workflow 2025-06-27 15:43:47 +03:00
Flaminel
bc306a37c9 Cleanuparr v2 (#166) 2025-06-27 15:39:26 +03:00
Flaminel
aab0487020 Updated blacklists 2025-06-25 16:19:35 +03:00
Flaminel
ca892ce188 Updated blacklists 2025-06-23 00:41:07 +03:00
Flaminel
cff5dc20e5 Update blacklists 2025-06-21 23:41:19 +03:00
412 changed files with 28268 additions and 6085 deletions

View File

@@ -14,7 +14,7 @@ body:
options: options:
- label: Reviewed the documentation. - label: Reviewed the documentation.
required: true required: true
- label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository. - label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
required: true required: true
- label: Ensured I am using the latest version. - label: Ensured I am using the latest version.
required: true required: true

View File

@@ -14,7 +14,7 @@ body:
options: options:
- label: Reviewed the documentation. - label: Reviewed the documentation.
required: true required: true
- label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository. - label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
required: true required: true
- label: Ensured I am using the latest version. - label: Ensured I am using the latest version.
required: true required: true

View File

@@ -29,6 +29,8 @@ jobs:
githubHeadRef=${{ env.githubHeadRef }} githubHeadRef=${{ env.githubHeadRef }}
latestDockerTag="" latestDockerTag=""
versionDockerTag="" versionDockerTag=""
majorVersionDockerTag=""
minorVersionDockerTag=""
version="0.0.1" version="0.0.1"
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
@@ -36,6 +38,12 @@ jobs:
latestDockerTag="latest" latestDockerTag="latest"
versionDockerTag=${branch#v} versionDockerTag=${branch#v}
version=${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 else
# Determine if this run is for the main branch or another branch # Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then if [[ -z "$githubHeadRef" ]]; then
@@ -53,11 +61,16 @@ jobs:
githubTags="" githubTags=""
if [ -n "$latestDockerTag" ]; then if [ -n "$latestDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr:$latestDockerTag" githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
fi fi
if [ -n "$versionDockerTag" ]; then if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr:$versionDockerTag" 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 fi
# set env vars # set env vars
@@ -113,7 +126,7 @@ jobs:
version=${{ env.versionDockerTag }} version=${{ env.versionDockerTag }}
build-args: | build-args: |
VERSION=${{ env.version }} VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ env.PACKAGES_USERNAME }} PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }} PACKAGES_PAT=${{ env.PACKAGES_PAT }}
outputs: | outputs: |
type=image type=image

View File

@@ -101,28 +101,6 @@ jobs:
- name: Build osx-arm64 - 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 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: Create sample configuration files
run: |
# Create a sample appsettings.json for each platform
cat > sample-config.json << 'EOF'
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
EOF
# Copy to each build directory
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/appsettings.json
- name: Zip win-x64 - name: Zip win-x64
run: | run: |
cd ./artifacts cd ./artifacts
@@ -156,22 +134,4 @@ jobs:
./artifacts/*.zip ./artifacts/*.zip
retention-days: 30 retention-days: 30
- name: Release # Removed individual release step - handled by main release workflow
if: startsWith(github.ref, 'refs/tags/')
id: release
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
fail_on_unmatched_files: true
target_commitish: main
generate_release_notes: true
files: |
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}' path: '${{ env.pkgName }}'
retention-days: 30 retention-days: 30
- name: Release # Removed individual release step - handled by main release workflow
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}' path: '${{ env.pkgName }}'
retention-days: 30 retention-days: 30
- name: Release # Removed individual release step - handled by main release workflow
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}

View File

@@ -88,19 +88,6 @@ jobs:
run: | run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
- name: Create sample configuration
shell: pwsh
run: |
# Create config directory
New-Item -ItemType Directory -Force -Path "config"
$config = @{
"HTTP_PORTS" = 11011
"BASE_PATH" = "/"
}
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
- name: Setup Inno Setup - name: Setup Inno Setup
shell: pwsh shell: pwsh
run: | run: |
@@ -158,14 +145,4 @@ jobs:
path: installer/${{ env.installerName }} path: installer/${{ env.installerName }}
retention-days: 30 retention-days: 30
- name: Release # Removed individual release step - handled by main release workflow
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
installer/${{ env.installerName }}

View 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

View 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

View File

@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
on: on:
push: push:
branches: [main] tags:
paths: - "v*.*.*"
- 'docs/**' workflow_dispatch: {}
permissions: permissions:
contents: read contents: read
@@ -22,6 +22,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -55,7 +55,7 @@ jobs:
# Build portable executables # Build portable executables
build-executables: build-executables:
needs: validate needs: validate
uses: ./.github/workflows/build_executable.yml uses: ./.github/workflows/build-executable.yml
secrets: inherit secrets: inherit
# Build Windows installer # Build Windows installer
@@ -106,12 +106,12 @@ jobs:
- name: Create release - name: Create release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: Cleanuparr ${{ needs.validate.outputs.release_version }} name: ${{ needs.validate.outputs.release_version }}
tag_name: ${{ needs.validate.outputs.release_version }} tag_name: ${{ needs.validate.outputs.release_version }}
token: ${{ env.REPO_READONLY_PAT }} token: ${{ env.REPO_READONLY_PAT }}
make_latest: true make_latest: true
target_commitish: main
generate_release_notes: true generate_release_notes: true
prerelease: ${{ contains(needs.validate.outputs.app_version, '-') }}
files: | files: |
./artifacts/**/*.zip ./artifacts/**/*.zip
./artifacts/**/*.pkg ./artifacts/**/*.pkg

3
Cloudflare/_headers Normal file
View File

@@ -0,0 +1,3 @@
# Cache static files for 5 minutes
/static/*
Cache-Control: public, max-age=300, s-maxage=300

View File

@@ -0,0 +1,2 @@
thepirateheaven.org
RARBG.work

View File

@@ -12,34 +12,71 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> **Features:** > **Features:**
> - Strike system to mark bad downloads. > - Strike system to mark bad downloads.
> - Remove and block downloads that reached a maximum number of strikes. > - Remove and block downloads that reached a maximum number of strikes.
> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/import-failed) > - 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. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/stalled) > - 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**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/slow) > - 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**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/content-blocker/general) > - 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. > - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/seeding) > - 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). [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/hardlinks) > - 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. [configuration](https://cleanuparr.github.io/cleanuparr/docs/category/notifications) > - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr. > - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment. ## 🎯 Supported Applications
## Quick Start ### *Arr Applications
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
> [!NOTE] ### Download Clients
> - **qBittorrent**
> 1. **Docker (Recommended)** - **Transmission**
> Pull the Docker image from `ghcr.io/Cleanuparr/Cleanuparr:latest`. - **Deluge**
> - **µTorrent**
> 2. **Unraid (for Unraid users)**
> Use the Unraid Community App.
>
> 3. **Manual Installation (if you're not using Docker)**
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
# Docs ### Platforms
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
Docs can be found [here](https://Cleanuparr.github.io/Cleanuparr/). ## 🚀 Quick Start
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
-p 11011:11011 \
-v /path/to/config:/config \
-e PORT=11011 \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface
After installation, open your browser and navigate to:
```
http://localhost:11011
```
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
## 📖 Documentation & Support
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
# <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> # <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>

346
blacklist
View File

@@ -1,12 +1,27 @@
*(sample).* *(sample).*
*.0xe *sample.avchd
*sample.avi
*sample.mkv
*sample.mov
*sample.mp4
*sample.webm
*sample.wmv
*.000
*.001 *.001
*.002
*.004
*.0xe
*.73k *.73k
*.73p *.73p
*.7z *.7z
*.7z.001
*.7z.002
*.89k *.89k
*.89z *.89z
*.8ck *.8ck
*.a00
*.a01
*.a02
*.a7r *.a7r
*.ac *.ac
*.acc *.acc
@@ -22,8 +37,11 @@
*.ahk *.ahk
*.ai *.ai
*.aif *.aif
*.ain
*.air *.air
*.alz *.alz
*.ana
*.apex
*.api *.api
*.apk *.apk
*.app *.app
@@ -31,15 +49,28 @@
*.applescript *.applescript
*.application *.application
*.appx *.appx
*.apz
*.ar
*.arc *.arc
*.archiver
*.arduboy
*.arh
*.ari
*.arj *.arj
*.ark
*.arscript *.arscript
*.asb *.asb
*.asice
*.asp *.asp
*.aspx *.aspx
*.aspx-exe *.aspx-exe
*.atmx *.atmx
*.ayt
*.azw2 *.azw2
*.b1
*.b6z
*.b64
*.ba
*.ba_ *.ba_
*.bak *.bak
*.bas *.bas
@@ -47,26 +78,48 @@
*.bat *.bat
*.bdjo *.bdjo
*.bdmv *.bdmv
*.bdoc
*.beam *.beam
*.bh
*.bin *.bin
*.bmp *.bmp
*.bms *.bms
*.bndl
*.bns *.bns
*.boo
*.bsa *.bsa
*.btm *.btm
*.bundle
*.bz
*.bz2 *.bz2
*.bza
*.bzabw
*.bzip
*.bzip2
*.c *.c
*.c00
*.c01
*.c02
*.c10
*.cab *.cab
*.caction *.caction
*.car
*.cb7
*.cba
*.cbr
*.cbt
*.cbz
*.cci *.cci
*.cda *.cda
*.cdb *.cdb
*.cdz
*.cel *.cel
*.celx *.celx
*.cfs *.cfs
*.cgi *.cgi
*.cheat *.cheat
*.chm *.chm
*.cit
*.ckpt *.ckpt
*.cla *.cla
*.class *.class
@@ -76,9 +129,15 @@
*.coffee *.coffee
*.com *.com
*.command *.command
*.comppkg.hauptwerk.rar
*.comppkg_hauptwerk_rar
*.conda
*.conf *.conf
*.config *.config
*.cp9
*.cpgz
*.cpl *.cpl
*.cpt
*.crt *.crt
*.cs *.cs
*.csh *.csh
@@ -86,17 +145,27 @@
*.csproj *.csproj
*.css *.css
*.csv *.csv
*.ctx
*.ctz
*.cue *.cue
*.cur *.cur
*.cxarchive
*.cyw *.cyw
*.czip
*.daemon *.daemon
*.daf
*.dar
*.dat *.dat
*.data-00000-of-00001 *.data-00000-of-00001
*.db *.db
*.dd
*.deamon *.deamon
*.deb *.deb
*.dek *.dek
*.dgc
*.dist
*.diz *.diz
*.dl_
*.dld *.dld
*.dll *.dll
*.dmc *.dmc
@@ -113,19 +182,27 @@
*.dw *.dw
*.dword *.dword
*.dxl *.dxl
*.dz
*.e_e *.e_e
*.ear *.ear
*.ebacmd *.ebacmd
*.ebm *.ebm
*.ebs *.ebs
*.ebs2 *.ebs2
*.ecar
*.ecf *.ecf
*.ecs
*.ecsbx
*.edz
*.efw
*.egg
*.eham *.eham
*.elf *.elf
*.elf-so *.elf-so
*.email *.email
*.emu *.emu
*.epk *.epk
*.epi
*.es *.es
*.esh *.esh
*.etc *.etc
@@ -141,36 +218,62 @@
*.exz *.exz
*.ezs *.ezs
*.ezt *.ezt
*.f
*.f3z
*.fas *.fas
*.fba *.fba
*.fcx
*.fky *.fky
*.flac *.flac
*.flatpak *.flatpak
*.flv *.flv
*.fp8
*.fpi *.fpi
*.frs *.frs
*.fxp *.fxp
*.fzpz
*.gadget *.gadget
*.gar
*.gat *.gat
*.gca
*.gif *.gif
*.gifv *.gifv
*.gm9 *.gm9
*.gmz
*.gpe *.gpe
*.gpu *.gpu
*.gs *.gs
*.gz *.gz
*.gz2
*.gza
*.gzi
*.gzip
*.h5 *.h5
*.ha
*.ham *.ham
*.hbc
*.hbc2
*.hbe
*.hex *.hex
*.hki
*.hki1
*.hki2
*.hki3
*.hlp *.hlp
*.hms *.hms
*.hpf *.hpf
*.hpk
*.hpkg
*.hta *.hta
*.hta-psh *.hta-psh
*.htaccess *.htaccess
*.htm *.htm
*.html *.html
*.htmi
*.hyp
*.iadproj
*.icd *.icd
*.ice
*.icns *.icns
*.ico *.ico
*.idx *.idx
@@ -183,17 +286,27 @@
*.ins *.ins
*.ipa *.ipa
*.ipf *.ipf
*.ipg
*.ipk *.ipk
*.ipsw *.ipsw
*.iqylink *.iqylink
*.ish
*.iso *.iso
*.isp *.isp
*.isu *.isu
*.isx
*.ita *.ita
*.ize
*.izh *.izh
*.izma ace *.izma ace
*.j
*.jar *.jar
*.jar.pack
*.java *.java
*.jex
*.jgz
*.jhh
*.jic
*.jpeg *.jpeg
*.jpg *.jpg
*.js *.js
@@ -202,27 +315,51 @@
*.jse *.jse
*.jsf *.jsf
*.json *.json
*.jsonlz4
*.jsp *.jsp
*.jsx *.jsx
*.kextraction
*.kgb
*.kix *.kix
*.ksh *.ksh
*.ksp
*.kwgt
*.kx *.kx
*.kz
*.layout
*.lbr
*.lck *.lck
*.ldb *.ldb
*.lemon
*.lha
*.lhzd
*.lib *.lib
*.libzip
*.link *.link
*.lnk *.lnk
*.lo *.lo
*.lock *.lock
*.log *.log
*.loop-vbs *.loop-vbs
*.lpkg
*.lqr
*.ls *.ls
*.lz
*.lz4
*.lzh
*.lzm
*.lzma
*.lzo
*.lzr
*.lzx
*.m3u *.m3u
*.m4a *.m4a
*.mac *.mac
*.macho *.macho
*.mamc *.mamc
*.manifest *.manifest
*.mar
*.mbz
*.mcr *.mcr
*.md *.md
*.mda *.mda
@@ -233,22 +370,29 @@
*.mdt *.mdt
*.mel *.mel
*.mem *.mem
*.memo
*.meta *.meta
*.mgm *.mgm
*.mhm *.mhm
*.mht *.mht
*.mhtml *.mhtml
*.mid *.mid
*.mint
*.mio *.mio
*.mlappinstall *.mlappinstall
*.mlproj
*.mlx *.mlx
*.mm *.mm
*.mobileconfig *.mobileconfig
*.model *.model
*.moo *.moo
*.mou
*.movpkg
*.mozlz4
*.mp3 *.mp3
*.mpa *.mpa
*.mpk *.mpk
*.mpkg
*.mpls *.mpls
*.mrc *.mrc
*.mrp *.mrp
@@ -267,41 +411,79 @@
*.msp *.msp
*.mst *.mst
*.msu *.msu
*.mxc
*.mxe *.mxe
*.mzp
*.n *.n
*.nar
*.ncl *.ncl
*.net *.net
*.nex
*.nexe *.nexe
*.nfo *.nfo
*.npk
*.nrg *.nrg
*.num *.num
*.nz
*.nzb.bz2 *.nzb.bz2
*.nzb.gz *.nzb.gz
*.nzbs *.nzbs
*.oar
*.ocx *.ocx
*.odlgz
*.odt *.odt
*.opk
*.ore *.ore
*.osf
*.ost *.ost
*.osx *.osx
*.osx-app *.osx-app
*.otm *.otm
*.out *.out
*.ova *.ova
*.oz
*.p *.p
*.p01
*.p19
*.p7z
*.pa
*.pack.gz
*.package
*.pae
*.paf *.paf
*.pak *.pak
*.paq6
*.paq7
*.paq8
*.paq8f
*.paq8l
*.paq8p
*.par
*.par2
*.pax
*.pb *.pb
*.pbi
*.pcd *.pcd
*.pcv
*.pdb *.pdb
*.pdf *.pdf
*.pea *.pea
*.perl *.perl
*.pet
*.pex *.pex
*.pf
*.phar *.phar
*.php *.php
*.php5 *.php5
*.pif *.pif
*.pim
*.pima
*.pit
*.piz
*.pkg *.pkg
*.pkg.tar.xz
*.pkg.tar.zst
*.pkz
*.pl *.pl
*.plsc *.plsc
*.plx *.plx
@@ -319,6 +501,7 @@
*.pptx *.pptx
*.prc *.prc
*.prg *.prg
*.prs
*.ps *.ps
*.ps1 *.ps1
*.ps1xml *.ps1xml
@@ -334,9 +517,16 @@
*.psh-reflection *.psh-reflection
*.psm1 *.psm1
*.pst *.pst
*.psz
*.pt *.pt
*.pup
*.puz
*.pvd *.pvd
*.pvmp
*.pvmz
*.pwa
*.pwc *.pwc
*.pxl
*.pxo *.pxo
*.py *.py
*.pyc *.pyc
@@ -344,8 +534,20 @@
*.pyo *.pyo
*.python *.python
*.pyz *.pyz
*.q
*.qda
*.qit *.qit
*.qpx *.qpx
*.r0
*.r00
*.r01
*.r02
*.r03
*.r04
*.r1
*.r2
*.r21
*.r30
*.ram *.ram
*.rar *.rar
*.raw *.raw
@@ -356,22 +558,35 @@
*.reg *.reg
*.resources *.resources
*.resx *.resx
*.rev
*.rfs *.rfs
*.rfu *.rfu
*.rgs *.rgs
*.rk
*.rm *.rm
*.rnc
*.rox *.rox
*.rp9
*.rpg *.rpg
*.rpj *.rpj
*.rpm *.rpm
*.rss
*.ruby *.ruby
*.run *.run
*.rxe *.rxe
*.rz
*.s00
*.s01
*.s02
*.s09
*.s2a *.s2a
*.s7z
*.sample *.sample
*.sapk *.sapk
*.sar
*.savedmodel *.savedmodel
*.sbs *.sbs
*.sbx
*.sca *.sca
*.scar *.scar
*.scb *.scb
@@ -381,42 +596,86 @@
*.scr *.scr
*.script *.script
*.sct *.sct
*.sdc
*.sdn
*.sdoc
*.sdocx
*.sea
*.seed *.seed
*.sen
*.server *.server
*.service *.service
*.sfg
*.sfm
*.sfs
*.sfv *.sfv
*.sfx
*.sh *.sh
*.shar
*.shb *.shb
*.shell *.shell
*.shk
*.shortcut *.shortcut
*.shr
*.shs *.shs
*.shtml *.shtml
*.sifz
*.sipa
*.sit *.sit
*.sitx *.sitx
*.sk *.sk
*.sldm *.sldm
*.sln *.sln
*.smm *.smm
*.smpf
*.snap *.snap
*.snagitstamps
*.snappy
*.snb
*.snd *.snd
*.snz
*.spa
*.spd
*.spl
*.spm
*.spr *.spr
*.spt
*.sql *.sql
*.sqf
*.sqx *.sqx
*.sqz
*.srec *.srec
*.srep
*.srt *.srt
*.ssm *.ssm
*.stg
*.stkdoodlz
*.stproj
*.sts *.sts
*.sub *.sub
*.svg *.svg
*.swf *.swf
*.sy_
*.sys *.sys
*.tar *.tar
*.tar.bz2
*.tar.gz *.tar.gz
*.tar.gz2
*.tar.lz
*.tar.lzma
*.tar.xz
*.tar.z
*.tar.zip
*.taz
*.tbl *.tbl
*.tbz *.tbz
*.tbz2
*.tcp *.tcp
*.tcx
*.text *.text
*.tf *.tf
*.tg
*.tgs
*.tgz *.tgz
*.thm *.thm
*.thmx *.thmx
@@ -425,19 +684,35 @@
*.tif *.tif
*.tiff *.tiff
*.tipa *.tipa
*.tlz
*.tlzma
*.tmp *.tmp
*.tms *.tms
*.toast *.toast
*.torrent *.torrent
*.tpk *.tpk
*.tpsr
*.trs
*.txt *.txt
*.tx_
*.txz
*.tz
*.tzst
*.u3p *.u3p
*.ubz
*.uc2
*.udf *.udf
*.ufdr
*.ufs.uzip
*.uha
*.upk *.upk
*.upx *.upx
*.url *.url
*.uue
*.uvm *.uvm
*.uw8 *.uw8
*.uzed
*.uzip
*.vb *.vb
*.vba *.vba
*.vba-exe *.vba-exe
@@ -449,26 +724,46 @@
*.vbscript *.vbscript
*.vcd *.vcd
*.vdo *.vdo
*.vem
*.vexe *.vexe
*.vfs
*.vhd *.vhd
*.vhdx *.vhdx
*.vib
*.vip
*.vlx *.vlx
*.vm *.vm
*.vmcz
*.vmdk *.vmdk
*.vms
*.vob *.vob
*.vocab *.vocab
*.voca
*.vpk
*.vpm *.vpm
*.vrpackage
*.vsi
*.vwi
*.vxp *.vxp
*.wa
*.wacz
*.waff
*.war *.war
*.wastickers
*.wav *.wav
*.wbk *.wbk
*.wcm *.wcm
*.wdz
*.webm *.webm
*.whl
*.wick
*.widget *.widget
*.wim *.wim
*.wiz *.wiz
*.wlb
*.wma *.wma
*.workflow *.workflow
*.wot
*.wpk *.wpk
*.wpl *.wpl
*.wpm *.wpm
@@ -477,14 +772,26 @@
*.wsc *.wsc
*.wsf *.wsf
*.wsh *.wsh
*.wux
*.x86 *.x86
*.x86_64 *.x86_64
*.xaml *.xaml
*.xap *.xap
*.xapk
*.xar
*.xbap *.xbap
*.xbe *.xbe
*.xcf.bz2
*.xcf.gz
*.xcf.xz
*.xcfbz2
*.xcfgz
*.xcfxz
*.xex *.xex
*.xez
*.xfp
*.xig *.xig
*.xip
*.xla *.xla
*.xlam *.xlam
*.xll *.xll
@@ -497,24 +804,47 @@
*.xltb *.xltb
*.xltm *.xltm
*.xlw *.xlw
*.xmcdz
*.xml *.xml
*.xoj
*.xopp
*.xqt *.xqt
*.xrt *.xrt
*.xx
*.xys *.xys
*.xz *.xz
*.xzm
*.y
*.yc
*.ygh *.ygh
*.yz1
*.z *.z
*.z00
*.z01
*.z02
*.z03
*.z04
*.zabw
*.zap
*.zed
*.zfsendtotarget
*.zhelp
*.zi
*.zi_
*.zim
*.zip *.zip
*.zipx *.zipx
*.zix
*.zl
*.zl9 *.zl9
*.zoo *.zoo
*sample.avchd *.zpaq
*sample.avi *.zpi
*sample.mkv *.zsplit
*sample.mov *.zst
*sample.mp4 *.zw
*sample.webm *.zwi
*sample.wmv *.zz
Trailer.* Trailer.*
VOSTFR VOSTFR
api api

View File

@@ -1,53 +1,410 @@
*.000
*.001 *.001
*.002
*.004
*.7z
*.7z.001
*.7z.002
*.a00
*.a01
*.a02
*.ace
*.ain
*.alz
*.ana
*.apex
*.apk *.apk
*.apz
*.ar
*.arc
*.archiver
*.arduboy
*.arh
*.ari
*.arj *.arj
*.ark
*.asice
*.ayt
*.b1
*.b6z
*.b64
*.ba
*.bat *.bat
*.bdoc
*.bh
*.bin *.bin
*.bmp *.bmp
*.bndl
*.boo
*.bundle
*.bz
*.bz2
*.bza
*.bzabw
*.bzip
*.bzip2
*.c00
*.c01
*.c02
*.c10
*.car
*.cb7
*.cba
*.cbr
*.cbt
*.cbz
*.cdz
*.cit
*.cmd *.cmd
*.com *.com
*.comppkg.hauptwerk.rar
*.comppkg_hauptwerk_rar
*.conda
*.cp9
*.cpgz
*.cpt
*.ctx
*.ctz
*.cxarchive
*.czip
*.daf
*.dar
*.db *.db
*.dd
*.deb
*.dgc
*.dist
*.diz *.diz
*.dl_
*.dll *.dll
*.dmg *.dmg
*.dz
*.ecar
*.ecs
*.ecsbx
*.edz
*.efw
*.egg
*.epi
*.etc *.etc
*.exe *.exe
*.f
*.f3z
*.fcx
*.fp8
*.fzpz
*.gar
*.gca
*.gif *.gif
*.gmz
*.gz
*.gz2
*.gza
*.gzi
*.gzip
*.ha
*.hbc
*.hbc2
*.hbe
*.hki
*.hki1
*.hki2
*.hki3
*.hpk
*.hpkg
*.htm *.htm
*.html *.html
*.htmi
*.hyp
*.iadproj
*.ice
*.ico *.ico
*.ini *.ini
*.ipg
*.ipk
*.ish
*.iso *.iso
*.isx
*.ita
*.ize
*.j
*.jar *.jar
*.jar.pack
*.jex
*.jgz
*.jhh
*.jic
*.jpg *.jpg
*.js *.js
*.jsonlz4
*.kextraction
*.kgb
*.ksp
*.kwgt
*.kz
*.layout
*.lbr
*.lemon
*.lha
*.lhzd
*.libzip
*.link *.link
*.lnk *.lnk
*.lpkg
*.lqr
*.lz
*.lz4
*.lzh
*.lzm
*.lzma
*.lzo
*.lzr
*.lzx
*.mar
*.mbz
*.md
*.memo
*.mint
*.mlproj
*.mou
*.movpkg
*.mozlz4
*.mpkg
*.msi *.msi
*.mxc
*.mzp
*.nar
*.nex
*.nfo *.nfo
*.npk
*.nz
*.oar
*.odlgz
*.opk
*.osf
*.oz
*.p01
*.p19
*.p7z
*.pa
*.pack.gz
*.package
*.pae
*.pak
*.paq6
*.paq7
*.paq8
*.paq8f
*.paq8l
*.paq8p
*.par
*.par2
*.pax
*.pbi
*.pcv
*.pea
*.perl *.perl
*.pet
*.pf
*.php *.php
*.pim
*.pima
*.pit
*.piz
*.pkg
*.pkg.tar.xz
*.pkg.tar.zst
*.pkz
*.pl *.pl
*.png *.png
*.prs
*.ps1 *.ps1
*.psc1 *.psc1
*.psd1 *.psd1
*.psm1 *.psm1
*.psz
*.pup
*.puz
*.pvmp
*.pvmz
*.pwa
*.pxl
*.py *.py
*.pyd *.pyd
*.q
*.qda
*.r0
*.r00
*.r01
*.r02
*.r03
*.r04
*.r1
*.r2
*.r21
*.r30
*.rar
*.rb *.rb
*.readme *.readme
*.reg *.reg
*.rev
*.rk
*.rnc
*.rp9
*.rpm
*.rss
*.run *.run
*.rz
*.s00
*.s01
*.s02
*.s09
*.s7z
*.sar
*.sbx
*.scr *.scr
*.sdc
*.sdn
*.sdoc
*.sdocx
*.sea
*.sen
*.sfg
*.sfm
*.sfs
*.sfx
*.sh *.sh
*.shar
*.shk
*.shr
*.sifz
*.sipa
*.sit
*.sitx
*.smpf
*.snagitstamps
*.snappy
*.snb
*.snz
*.spa
*.spd
*.spl
*.spm
*.spt
*.sql *.sql
*.sqf
*.sqx
*.sqz
*.srep
*.stg
*.stkdoodlz
*.stproj
*.sy_
*.tar.bz2
*.tar.gz
*.tar.gz2
*.tar.lz
*.tar.lzma
*.tar.xz
*.tar.z
*.tar.zip
*.taz
*.tbz
*.tbz2
*.tcx
*.text *.text
*.tg
*.tgs
*.tgz
*.thumb *.thumb
*.tlz
*.tlzma
*.torrent *.torrent
*.tpsr
*.trs
*.txt *.txt
*.tx_
*.txz
*.tz
*.tzst
*.ubz
*.uc2
*.ufdr
*.ufs.uzip
*.uha
*.url *.url
*.uue
*.uvm
*.uzed
*.uzip
*.vbs *.vbs
*.vem
*.vfs
*.vib
*.vip
*.vmcz
*.vms
*.voca
*.vpk
*.vrpackage
*.vsi
*.vwi
*.wa
*.wacz
*.waff
*.war
*.wastickers
*.wdz
*.whl
*.wick
*.wlb
*.wot
*.wsf *.wsf
*.wux
*.xapk
*.xar
*.xcf.bz2
*.xcf.gz
*.xcf.xz
*.xcfbz2
*.xcfgz
*.xcfxz
*.xez
*.xfp
*.xip
*.xml *.xml
*.zipx *.xmcdz
*.xoj
*.xopp
*.xx
*.xz
*.xzm
*.y
*.yc
*.yz1
*.z
*.z00
*.z01
*.z02
*.z03
*.z04
*.zabw
*.zap
*.zed
*.zfsendtotarget
*.zhelp
*.zi
*.zi_
*.zim
*.zip
*.zipx
*.zix
*.zl
*.zoo
*.zpaq
*.zpi
*.zsplit
*.zst
*.zw
*.zwi
*.zz

View File

@@ -45,6 +45,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management and timezone support # Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
curl \
tzdata \ tzdata \
gosu \ gosu \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

File diff suppressed because it is too large Load Diff

View File

@@ -87,10 +87,6 @@ public class EventsController : ControllerBase
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
events = events
.OrderBy(e => e.Timestamp)
.ToList();
// Return paginated result // Return paginated result
var result = new PaginatedResult<AppEvent> var result = new PaginatedResult<AppEvent>
{ {

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Health check endpoints for Docker and Kubernetes
/// </summary>
[ApiController]
[Route("[controller]")]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
private readonly ILogger<HealthController> _logger;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
/// <summary>
/// Basic liveness probe - checks if the application is running
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
/// </summary>
[HttpGet]
[Route("/health")]
public async Task<IActionResult> GetHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("liveness"));
return result.Status == HealthStatus.Healthy
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Readiness probe - checks if the application is ready to serve traffic
/// Used by Kubernetes readiness probes
/// </summary>
[HttpGet]
[Route("/health/ready")]
public async Task<IActionResult> GetReadiness()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("readiness"));
if (result.Status == HealthStatus.Healthy)
{
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
}
// For readiness, we consider degraded as not ready
return StatusCode(503, new {
status = "not_ready",
timestamp = DateTime.UtcNow,
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
.ToDictionary(e => e.Key, e => new {
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Readiness check failed");
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Detailed health status - for monitoring and debugging
/// </summary>
[HttpGet]
[Route("/health/detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync();
var response = new
{
status = result.Status.ToString().ToLowerInvariant(),
timestamp = DateTime.UtcNow,
totalDuration = result.TotalDuration.TotalMilliseconds,
entries = result.Entries.ToDictionary(
e => e.Key,
e => new
{
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
tags = e.Value.Tags,
data = e.Value.Data,
exception = e.Value.Exception?.Message
})
};
return result.Status == HealthStatus.Healthy
? Ok(response)
: StatusCode(503, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Detailed health check failed");
return StatusCode(503, new {
status = "unhealthy",
error = "Detailed health check failed",
timestamp = DateTime.UtcNow
});
}
}
}

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Api.Models; using Cleanuparr.Api.Models;
using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Models;
using Infrastructure.Services.Interfaces; using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers; namespace Cleanuparr.Api.Controllers;

View File

@@ -52,6 +52,10 @@ public class StatusController : ControllerBase
.Include(x => x.Instances) .Include(x => x.Instances)
.AsNoTracking() .AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr); .FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new var status = new
{ {
@@ -80,6 +84,10 @@ public class StatusController : ControllerBase
Lidarr = new Lidarr = new
{ {
InstanceCount = lidarrConfig.Instances.Count InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
} }
} }
}; };

View File

@@ -40,9 +40,6 @@ public static class ApiDI
// Add health status broadcaster // Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>(); services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options => services.AddSwaggerGen(options =>
{ {
options.SwaggerDoc("v1", new OpenApiInfo options.SwaggerDoc("v1", new OpenApiInfo
@@ -142,6 +139,38 @@ public static class ApiDI
// Map SignalR hubs // Map SignalR hubs
app.MapHub<HealthStatusHub>("/api/hubs/health"); app.MapHub<HealthStatusHub>("/api/hubs/health");
app.MapHub<AppHub>("/api/hubs/app"); app.MapHub<AppHub>("/api/hubs/app");
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
{
var basePath = context.Request.PathBase.HasValue
? context.Request.PathBase.Value
: "/";
var manifest = new
{
name = "Cleanuparr",
short_name = "Cleanuparr",
start_url = basePath,
display = "standalone",
background_color = "#ffffff",
theme_color = "#ffffff",
icons = new[]
{
new {
src = "icons/icon-192x192.png",
sizes = "192x192",
type = "image/png"
},
new {
src = "icons/icon-512x512.png",
sizes = "512x512",
type = "image/png"
}
}
};
return Results.Json(manifest, contentType: "application/manifest+json");
});
return app; return app;
} }

View File

@@ -1,10 +1,5 @@
using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Shared.Helpers;
using Serilog; using Serilog;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Cleanuparr.Api.DependencyInjection; namespace Cleanuparr.Api.DependencyInjection;
@@ -12,82 +7,10 @@ public static class LoggingDI
{ {
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder) public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
{ {
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger(); Log.Logger = LoggingConfigManager
.CreateLoggerConfiguration()
.CreateLogger();
return builder.ClearProviders().AddSerilog(); 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;
}
} }

View File

@@ -1,11 +1,13 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers; using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers; using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health; using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Data.Models.Arr; using Data.Models.Arr;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit; using MassTransit;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@@ -15,22 +17,26 @@ public static class MainDI
{ {
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) => public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
services services
.AddLogging(builder => builder.ClearProviders().AddConsole())
.AddHttpClients(configuration) .AddHttpClients(configuration)
.AddSingleton<MemoryCache>() .AddSingleton<MemoryCache>()
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>()) .AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
.AddServices() .AddServices()
.AddHealthServices() .AddHealthServices()
.AddQuartzServices(configuration) .AddQuartzServices(configuration)
.AddNotifications(configuration) .AddNotifications()
.AddMassTransit(config => .AddMassTransit(config =>
{ {
config.DisableUsageTelemetry();
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>(); config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(); config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>(); config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>(); config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>(); config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>(); config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>(); config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>(); config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
@@ -48,7 +54,15 @@ public static class MainDI
cfg.ReceiveEndpoint("download-remover-queue", e => cfg.ReceiveEndpoint("download-remover-queue", e =>
{ {
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context); e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context); e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 1; e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1; e.PrefetchCount = 1;
}); });
@@ -57,7 +71,8 @@ public static class MainDI
{ {
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context); e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(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<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context); e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context); e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
@@ -83,9 +98,17 @@ public static class MainDI
/// </summary> /// </summary>
private static IServiceCollection AddHealthServices(this IServiceCollection services) => private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
services services
// Register the health check service // Register the existing health check service for download clients
.AddSingleton<IHealthCheckService, HealthCheckService>() .AddSingleton<IHealthCheckService, HealthCheckService>()
// Register the background service for periodic health checks // Register the background service for periodic health checks
.AddHostedService<HealthCheckBackgroundService>(); .AddHostedService<HealthCheckBackgroundService>()
// Add ASP.NET Core health checks
.AddHealthChecks()
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
.Services;
} }

View File

@@ -1,20 +1,20 @@
using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Infrastructure.Verticals.Notifications; using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
namespace Cleanuparr.Api.DependencyInjection; namespace Cleanuparr.Api.DependencyInjection;
public static class NotificationsDI public static class NotificationsDI
{ {
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) => public static IServiceCollection AddNotifications(this IServiceCollection services) =>
services services
// Notification configs are now managed through ConfigManager .AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotifiarrProxy, NotifiarrProxy>() .AddScoped<IAppriseProxy, AppriseProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>() .AddScoped<INtfyProxy, NtfyProxy>()
.AddTransient<IAppriseProxy, AppriseProxy>() .AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddTransient<INotificationProvider, AppriseProvider>() .AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddTransient<INotificationPublisher, NotificationPublisher>() .AddScoped<NotificationProviderFactory>()
.AddTransient<INotificationFactory, NotificationFactory>() .AddScoped<INotificationPublisher, NotificationPublisher>()
.AddTransient<NotificationService>(); .AddScoped<NotificationService>();
} }

View File

@@ -1,20 +1,24 @@
using Cleanuparr.Application.Features.ContentBlocker; using Cleanuparr.Application.Features.BlacklistSync;
using Cleanuparr.Application.Features.DownloadCleaner; using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.DownloadClient;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner; using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr; using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover; using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security; using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence; using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Infrastructure.Services.Interfaces;
using Infrastructure.Verticals.Files; using Infrastructure.Verticals.Files;
namespace Cleanuparr.Api.DependencyInjection; namespace Cleanuparr.Api.DependencyInjection;
@@ -23,31 +27,34 @@ public static class ServicesDI
{ {
public static IServiceCollection AddServices(this IServiceCollection services) => public static IServiceCollection AddServices(this IServiceCollection services) =>
services services
.AddSingleton<IEncryptionService, AesEncryptionService>() .AddScoped<IEncryptionService, AesEncryptionService>()
.AddTransient<SensitiveDataJsonConverter>() .AddScoped<SensitiveDataJsonConverter>()
.AddTransient<EventsContext>() .AddScoped<EventsContext>()
.AddTransient<DataContext>() .AddScoped<DataContext>()
.AddTransient<EventPublisher>() .AddScoped<EventPublisher>()
.AddHostedService<EventCleanupService>() .AddHostedService<EventCleanupService>()
// API services .AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<CertificateValidationService>()
.AddScoped<SonarrClient>()
.AddScoped<RadarrClient>()
.AddScoped<LidarrClient>()
.AddScoped<ReadarrClient>()
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<ArrQueueIterator>()
.AddScoped<DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddSingleton<IJobManagementService, JobManagementService>() .AddSingleton<IJobManagementService, JobManagementService>()
// Core services
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<CertificateValidationService>()
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()
.AddTransient<DownloadCleaner>()
.AddTransient<IQueueItemRemover, QueueItemRemover>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<IHardLinkFileService, HardLinkFileService>()
.AddTransient<UnixHardLinkFileService>()
.AddTransient<WindowsHardLinkFileService>()
.AddTransient<ArrQueueIterator>()
.AddTransient<DownloadServiceFactory>()
.AddTransient<IStriker, Striker>()
.AddSingleton<BlocklistProvider>(); .AddSingleton<BlocklistProvider>();
} }

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text;
namespace Cleanuparr.Api;
/// <summary>
/// Custom health check response writers for different formats
/// </summary>
public static class HealthCheckResponseWriter
{
/// <summary>
/// Writes a minimal plain text response suitable for Docker health checks
/// </summary>
public static async Task WriteMinimalPlaintext(HttpContext context, HealthReport report)
{
context.Response.ContentType = "text/plain";
var status = report.Status switch
{
HealthStatus.Healthy => "healthy",
HealthStatus.Degraded => "degraded",
HealthStatus.Unhealthy => "unhealthy",
_ => "unknown"
};
await context.Response.WriteAsync(status, Encoding.UTF8);
}
}

View File

@@ -6,7 +6,7 @@ namespace Cleanuparr.Api;
public static class HostExtensions public static class HostExtensions
{ {
public static async Task<IHost> Init(this WebApplication app) public static async Task<IHost> InitAsync(this WebApplication app)
{ {
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>(); ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
@@ -20,19 +20,25 @@ public static class HostExtensions
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName); logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
// Apply db migrations return app;
var eventsContext = app.Services.GetRequiredService<EventsContext>(); }
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()) if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
{ {
await eventsContext.Database.MigrateAsync(); 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()) if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{ {
await configContext.Database.MigrateAsync(); await configContext.Database.MigrateAsync();
} }
return app; return builder;
} }
} }

View File

@@ -1,13 +1,17 @@
using Cleanuparr.Application.Features.ContentBlocker; using Cleanuparr.Application.Features.BlacklistSync;
using Cleanuparr.Application.Features.DownloadCleaner; using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.DownloadClient;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner; using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions; using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Persistence; using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Shared.Helpers; using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Quartz; using Quartz;
@@ -22,18 +26,18 @@ namespace Cleanuparr.Api.Jobs;
public class BackgroundJobManager : IHostedService public class BackgroundJobManager : IHostedService
{ {
private readonly ISchedulerFactory _schedulerFactory; private readonly ISchedulerFactory _schedulerFactory;
private readonly DataContext _dataContext; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<BackgroundJobManager> _logger; private readonly ILogger<BackgroundJobManager> _logger;
private IScheduler? _scheduler; private IScheduler? _scheduler;
public BackgroundJobManager( public BackgroundJobManager(
ISchedulerFactory schedulerFactory, ISchedulerFactory schedulerFactory,
DataContext dataContext, IServiceScopeFactory scopeFactory,
ILogger<BackgroundJobManager> logger ILogger<BackgroundJobManager> logger
) )
{ {
_schedulerFactory = schedulerFactory; _schedulerFactory = schedulerFactory;
_dataContext = dataContext; _scopeFactory = scopeFactory;
_logger = logger; _logger = logger;
} }
@@ -45,12 +49,12 @@ public class BackgroundJobManager : IHostedService
{ {
try try
{ {
_logger.LogInformation("Starting BackgroundJobManager"); _logger.LogDebug("Starting BackgroundJobManager");
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken); _scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
await InitializeJobsFromConfiguration(cancellationToken); await InitializeJobsFromConfiguration(cancellationToken);
_logger.LogInformation("BackgroundJobManager started"); _logger.LogDebug("BackgroundJobManager started");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -64,15 +68,15 @@ public class BackgroundJobManager : IHostedService
/// </summary> /// </summary>
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
_logger.LogInformation("Stopping BackgroundJobManager"); _logger.LogDebug("Stopping BackgroundJobManager");
if (_scheduler != null) 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); await _scheduler.Standby(cancellationToken);
} }
_logger.LogInformation("BackgroundJobManager stopped"); _logger.LogDebug("BackgroundJobManager stopped");
} }
/// <summary> /// <summary>
@@ -86,21 +90,28 @@ public class BackgroundJobManager : IHostedService
throw new InvalidOperationException("Scheduler not initialized"); throw new InvalidOperationException("Scheduler not initialized");
} }
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Get configurations from db // Get configurations from db
QueueCleanerConfig queueCleanerConfig = await _dataContext.QueueCleanerConfigs QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
.AsNoTracking() .AsNoTracking()
.FirstAsync(cancellationToken); .FirstAsync(cancellationToken);
ContentBlockerConfig contentBlockerConfig = await _dataContext.ContentBlockerConfigs ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking() .AsNoTracking()
.FirstAsync(cancellationToken); .FirstAsync(cancellationToken);
DownloadCleanerConfig downloadCleanerConfig = await _dataContext.DownloadCleanerConfigs DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
.AsNoTracking() .AsNoTracking()
.FirstAsync(cancellationToken); .FirstAsync(cancellationToken);
// Always register jobs, regardless of enabled status // Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken); await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken); await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken); await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
} }
/// <summary> /// <summary>
@@ -116,24 +127,24 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled // Only add triggers if the job is enabled
if (config.Enabled) if (config.Enabled)
{ {
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken); await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
} }
} }
/// <summary> /// <summary>
/// Registers the QueueCleaner job and optionally adds triggers based on configuration. /// Registers the QueueCleaner job and optionally adds triggers based on configuration.
/// </summary> /// </summary>
public async Task RegisterContentBlockerJob( public async Task RegisterMalwareBlockerJob(
ContentBlockerConfig config, ContentBlockerConfig config,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Always register the job definition // Always register the job definition
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken); await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled // Only add triggers if the job is enabled
if (config.Enabled) if (config.Enabled)
{ {
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken); await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
} }
} }
@@ -148,7 +159,21 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled // Only add triggers if the job is enabled
if (config.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 +181,9 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add triggers for an existing job. /// Helper method to add triggers for an existing job.
/// </summary> /// </summary>
private async Task AddTriggersForJob<T>( private async Task AddTriggersForJob<T>(
IJobConfig config,
string cronExpression, string cronExpression,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
where T : GenericHandler where T : IHandler
{ {
if (_scheduler == null) if (_scheduler == null)
{ {
@@ -175,7 +199,7 @@ public class BackgroundJobManager : IHostedService
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create() IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ValidationTrigger") .WithIdentity("ValidationTrigger")
.StartNow() .StartNow()
.WithCronSchedule(cronExpression) .WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build(); .Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2); IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
@@ -186,7 +210,7 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours"); 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"); throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
} }
@@ -197,26 +221,26 @@ public class BackgroundJobManager : IHostedService
} }
} }
// Create cron trigger // Create main cron trigger with consistent naming (matches JobManagementService)
var trigger = TriggerBuilder.Create() var trigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-trigger") .WithIdentity($"{typeName}-trigger")
.ForJob(jobKey) .ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing()) .WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.StartNow()
.Build(); .Build();
// Create startup trigger to run immediately // Schedule the main trigger
await _scheduler.ScheduleJob(trigger, cancellationToken);
// Trigger immediate execution for startup using a one-time trigger
var startupTrigger = TriggerBuilder.Create() var startupTrigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-startup-trigger") .WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey) .ForJob(jobKey)
.StartNow() .StartNow()
.Build(); .Build();
// Schedule job with both triggers
await _scheduler.ScheduleJob(trigger, cancellationToken);
await _scheduler.ScheduleJob(startupTrigger, cancellationToken); await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
_logger.LogInformation("Added triggers for job {name} with cron expression {CronExpression}", _logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
typeName, cronExpression); typeName, cronExpression);
} }
@@ -224,7 +248,7 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add a job without a trigger (for chained jobs). /// Helper method to add a job without a trigger (for chained jobs).
/// </summary> /// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default) private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
where T : GenericHandler where T : IHandler
{ {
if (_scheduler == null) if (_scheduler == null)
{ {
@@ -250,6 +274,6 @@ public class BackgroundJobManager : IHostedService
// Add job to scheduler // Add job to scheduler
await _scheduler.AddJob(jobDetail, true, cancellationToken); await _scheduler.AddJob(jobDetail, true, cancellationToken);
_logger.LogInformation("Registered job {name} without trigger", typeName); _logger.LogDebug("Registered job {name} without trigger", typeName);
} }
} }

View File

@@ -9,12 +9,12 @@ public sealed class GenericJob<T> : IJob
where T : IHandler where T : IHandler
{ {
private readonly ILogger<GenericJob<T>> _logger; private readonly ILogger<GenericJob<T>> _logger;
private readonly T _handler; private readonly IServiceScopeFactory _scopeFactory;
public GenericJob(ILogger<GenericJob<T>> logger, T handler) public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
{ {
_logger = logger; _logger = logger;
_handler = handler; _scopeFactory = scopeFactory;
} }
public async Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
@@ -23,7 +23,9 @@ public sealed class GenericJob<T> : IJob
try try
{ {
await _handler.ExecuteAsync(); await using var scope = _scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateAppriseProviderDto : CreateNotificationProviderBaseDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateNotifiarrProviderDto : CreateNotificationProviderBaseDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,20 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public abstract record CreateNotificationProviderBaseDto
{
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; }
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateNtfyProviderDto : CreateNotificationProviderBaseDto
{
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; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestAppriseProviderDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestNotifiarrProviderDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestNtfyProviderDto
{
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; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateAppriseProviderDto : CreateNotificationProviderBaseDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateNotifiarrProviderDto : CreateNotificationProviderBaseDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateNtfyProviderDto : CreateNotificationProviderBaseDto
{
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; } = [];
}

View File

@@ -29,6 +29,8 @@ public class UpdateDownloadCleanerConfigDto
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty; public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
public List<string> UnlinkedCategories { get; set; } = []; public List<string> UnlinkedCategories { get; set; } = [];
public List<string> IgnoredDownloads { get; set; } = [];
} }
public class CleanCategoryDto public class CleanCategoryDto

View File

@@ -2,12 +2,18 @@ using System.Runtime.InteropServices;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Cleanuparr.Api; using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection; using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging; using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers; using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.SignalR;
using Serilog; using Serilog;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
await builder.InitAsync();
builder.Logging.AddLogging();
// Fix paths for single-file deployment on macOS // Fix paths for single-file deployment on macOS
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{ {
@@ -66,14 +72,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)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
builder.Host.UseWindowsService(options => builder.Host.UseWindowsService(options =>
@@ -128,25 +126,27 @@ if (basePath is not null)
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/"); logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
// Initialize the host // Initialize the host
await app.Init(); await app.InitAsync();
// Get LoggingConfigManager (will be created if not already registered) // Configure the app hub for SignalR
var configManager = app.Services.GetRequiredService<LoggingConfigManager>(); var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// 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(); // Configure health check endpoints before the API configuration
logConfig.MinimumLevel.ControlledBy(levelSwitch); app.MapHealthChecks("/health", new HealthCheckOptions
{
// Add to Serilog pipeline Predicate = registration => registration.Tags.Contains("liveness"),
logConfig.WriteTo.Sink(signalRSink); ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
Log.Logger = logConfig.CreateLogger(); app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.ConfigureApi(); app.ConfigureApi();
await app.RunAsync(); await app.RunAsync();
await Log.CloseAndFlushAsync();

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateReadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Whisparr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateWhisparrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -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");
}
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr; using Cleanuparr.Infrastructure.Features.Arr;
@@ -10,7 +11,6 @@ using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.Configuration.General;
using Data.Models.Arr.Queue;
using MassTransit; using MassTransit;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -59,10 +59,11 @@ public sealed class DownloadCleaner : GenericHandler
return; return;
} }
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads; List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<DownloadCleanerConfig>().IgnoredDownloads);
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();
// Process each client separately
var allDownloads = new List<object>();
foreach (var downloadService in downloadServices) foreach (var downloadService in downloadServices)
{ {
try try
@@ -71,24 +72,24 @@ public sealed class DownloadCleaner : GenericHandler
var clientDownloads = await downloadService.GetSeedingDownloads(); var clientDownloads = await downloadService.GetSeedingDownloads();
if (clientDownloads?.Count > 0) if (clientDownloads?.Count > 0)
{ {
allDownloads.AddRange(clientDownloads); downloadServiceToDownloadsMap[downloadService] = clientDownloads;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to get seeding downloads from download client"); _logger.LogError(ex, "Failed to get seeding downloads from download client {clientName}", downloadService.ClientConfig.Name);
} }
} }
if (allDownloads.Count == 0) if (downloadServiceToDownloadsMap.Count == 0)
{ {
_logger.LogDebug("no seeding downloads found"); _logger.LogDebug("no seeding downloads found");
return; return;
} }
_logger.LogTrace("found {count} seeding downloads", allDownloads.Count); var totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count);
_logger.LogTrace("found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
// List<object>? downloadsToChangeCategory = null;
List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = []; List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = [];
if (isUnlinkedEnabled) if (isUnlinkedEnabled)
@@ -102,24 +103,23 @@ public sealed class DownloadCleaner : GenericHandler
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to create category for download client"); _logger.LogError(ex, "Failed to create category for download client {clientName}", downloadService.ClientConfig.Name);
} }
} }
// Get downloads to change category foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
foreach (var downloadService in downloadServices)
{ {
try try
{ {
var clientDownloads = downloadService.FilterDownloadsToChangeCategoryAsync(allDownloads, config.UnlinkedCategories); var downloadsToChangeCategory = downloadService.FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories);
if (clientDownloads?.Count > 0) if (downloadsToChangeCategory?.Count > 0)
{ {
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads)); downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToChangeCategory));
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to filter downloads for category change"); _logger.LogError(ex, "Failed to filter downloads for category change for download client {clientName}", downloadService.ClientConfig.Name);
} }
} }
} }
@@ -130,7 +130,9 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true); 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.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, 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) if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
{ {
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count)); _logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
@@ -156,16 +158,15 @@ public sealed class DownloadCleaner : GenericHandler
return; return;
} }
// Get downloads to clean
downloadServiceWithDownloads = []; downloadServiceWithDownloads = [];
foreach (var downloadService in downloadServices) foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{ {
try try
{ {
var clientDownloads = downloadService.FilterDownloadsToBeCleanedAsync(allDownloads, config.Categories); var downloadsToClean = downloadService.FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories);
if (clientDownloads?.Count > 0) if (downloadsToClean?.Count > 0)
{ {
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads)); downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToClean));
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -174,9 +175,6 @@ public sealed class DownloadCleaner : GenericHandler
} }
} }
// release unused objects
allDownloads = null;
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count)); _logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
// Process cleaning for each client // Process cleaning for each client

View File

@@ -1,31 +1,31 @@
using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr; using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence; using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.Configuration.General;
using Data.Models.Arr.Queue; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using MassTransit; using MassTransit;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext; using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Application.Features.ContentBlocker; namespace Cleanuparr.Application.Features.MalwareBlocker;
public sealed class ContentBlocker : GenericHandler public sealed class MalwareBlocker : GenericHandler
{ {
private readonly BlocklistProvider _blocklistProvider; private readonly BlocklistProvider _blocklistProvider;
public ContentBlocker( public MalwareBlocker(
ILogger<ContentBlocker> logger, ILogger<MalwareBlocker> logger,
DataContext dataContext, DataContext dataContext,
IMemoryCache cache, IMemoryCache cache,
IBus messageBus, IBus messageBus,
@@ -52,7 +52,7 @@ public sealed class ContentBlocker : GenericHandler
var config = ContextProvider.Get<ContentBlockerConfig>(); var config = ContextProvider.Get<ContentBlockerConfig>();
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled) if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
{ {
_logger.LogWarning("No blocklists are enabled"); _logger.LogWarning("No blocklists are enabled");
return; return;
@@ -63,26 +63,39 @@ public sealed class ContentBlocker : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)); var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)); var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)); var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled) if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{ {
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr); await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
} }
if (config.Radarr.Enabled) if (config.Radarr.Enabled || config.DeleteKnownMalware)
{ {
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr); await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
} }
if (config.Lidarr.Enabled) if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{ {
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr); await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
} }
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}
} }
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType) protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{ {
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads; List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString()); using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
@@ -171,6 +184,10 @@ public sealed class ContentBlocker : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title); _logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
} }
} }
else
{
_logger.LogDebug("No torrent clients enabled");
}
} }
if (!result.ShouldRemove) if (!result.ShouldRemove)
@@ -194,7 +211,7 @@ public sealed class ContentBlocker : GenericHandler
record, record,
group.Count() > 1, group.Count() > 1,
removeFromClient, removeFromClient,
DeleteReason.AllFilesBlocked result.DeleteReason
); );
} }
}); });

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr; using Cleanuparr.Infrastructure.Features.Arr;
@@ -11,7 +12,6 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr.Queue;
using MassTransit; using MassTransit;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -42,15 +42,20 @@ public sealed class QueueCleaner : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)); var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)); var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)); var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr); await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr); await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr); await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
} }
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType) protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{ {
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads; List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<QueueCleanerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString()); using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
@@ -103,7 +108,7 @@ public sealed class QueueCleaner : GenericHandler
DownloadCheckResult downloadCheckResult = new(); DownloadCheckResult downloadCheckResult = new();
if (record.Protocol is "torrent") if (record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase))
{ {
var torrentClients = downloadServices var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent) .Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
@@ -137,12 +142,16 @@ public sealed class QueueCleaner : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title); _logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
} }
} }
else
{
_logger.LogDebug("No torrent clients enabled");
}
} }
var config = ContextProvider.Get<QueueCleanerConfig>(); var config = ContextProvider.Get<QueueCleanerConfig>();
// failed import check // failed import check
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.FailedImport.MaxStrikes); bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport; DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove) if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record Image public record Image
{ {

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record LidarrImage public record LidarrImage
{ {

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueAlbum public sealed record QueueAlbum
{ {

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueBook
{
public List<ReadarrImage> Images { get; init; } = [];
}

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record QueueListResponse public record QueueListResponse
{ {

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueMovie public sealed record QueueMovie
{ {

View File

@@ -1,8 +1,8 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueRecord public sealed record QueueRecord
{ {
// Sonarr // Sonarr and Whisparr
public long SeriesId { get; init; } public long SeriesId { get; init; }
public long EpisodeId { get; init; } public long EpisodeId { get; init; }
public long SeasonNumber { get; init; } public long SeasonNumber { get; init; }
@@ -21,6 +21,13 @@ public sealed record QueueRecord
public QueueAlbum? Album { get; init; } public QueueAlbum? Album { get; init; }
// Readarr
public long AuthorId { get; init; }
public long BookId { get; init; }
public QueueBook? Book { get; init; }
// common // common
public required string Title { get; init; } public required string Title { get; init; }
public string Status { get; init; } public string Status { get; init; }

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueSeries public sealed record QueueSeries
{ {

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record ReadarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue; namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record TrackedDownloadStatusMessage public sealed record TrackedDownloadStatusMessage
{ {

View File

@@ -1,16 +1,17 @@
using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Enums;
using Data.Models.Arr;
namespace Data.Models.Arr; namespace Cleanuparr.Domain.Entities.Arr;
public sealed class SonarrSearchItem : SearchItem public sealed class SeriesSearchItem : SearchItem
{ {
public long SeriesId { get; set; } public long SeriesId { get; set; }
public SonarrSearchType SearchType { get; set; } public SeriesSearchType SearchType { get; set; }
public override bool Equals(object? obj) public override bool Equals(object? obj)
{ {
if (obj is not SonarrSearchItem other) if (obj is not SeriesSearchItem other)
{ {
return false; return false;
} }

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Author
{
public long Id { get; set; }
public string AuthorName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Book
{
public required long Id { get; init; }
public required string Title { get; init; }
public long AuthorId { get; set; }
public Author Author { get; set; } = new();
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record ReadarrCommand
{
public string Name { get; set; } = string.Empty;
public List<long> BookIds { get; set; } = [];
}

View File

@@ -12,5 +12,5 @@ public sealed record SonarrCommand
public List<long>? EpisodeIds { get; set; } public List<long>? EpisodeIds { get; set; }
public SonarrSearchType SearchType { get; set; } public SeriesSearchType SearchType { get; set; }
} }

View File

@@ -0,0 +1,69 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Request;
/// <summary>
/// Represents a request to the µTorrent Web UI API
/// </summary>
public sealed class UTorrentRequest
{
/// <summary>
/// The API action to perform
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// Authentication token (required for CSRF protection)
/// </summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// Additional parameters for the request
/// </summary>
public List<(string Name, string Value)> Parameters { get; set; } = new();
/// <summary>
/// Constructs the query string for the API call
/// </summary>
/// <returns>The complete query string including token and action</returns>
public string ToQueryString()
{
var queryParams = new List<string>
{
$"token={Token}",
Action
};
foreach (var param in Parameters)
{
queryParams.Add($"{Uri.EscapeDataString(param.Name)}={Uri.EscapeDataString(param.Value)}");
}
return string.Join("&", queryParams);
}
/// <summary>
/// Creates a new request with the specified action
/// </summary>
/// <param name="action">The API action</param>
/// <param name="token">Authentication token</param>
/// <returns>A new UTorrentRequest instance</returns>
public static UTorrentRequest Create(string action, string token)
{
return new UTorrentRequest
{
Action = action,
Token = token
};
}
/// <summary>
/// Adds a parameter to the request
/// </summary>
/// <param name="key">Parameter name</param>
/// <param name="value">Parameter value</param>
/// <returns>This instance for method chaining</returns>
public UTorrentRequest WithParameter(string key, string value)
{
Parameters.Add((key, value));
return this;
}
}

View File

@@ -0,0 +1,28 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for file list API calls
/// Replaces the generic UTorrentResponse<T> for file listings
/// </summary>
public sealed class FileListResponse
{
/// <summary>
/// Raw file data from the API
/// </summary>
[JsonProperty(PropertyName = "files")]
public object[]? FilesRaw { get; set; }
/// <summary>
/// Torrent hash for which files are listed
/// </summary>
[JsonIgnore]
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Parsed files as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentFile> Files { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for label list API calls
/// Replaces the generic UTorrentResponse<T> for label listings
/// </summary>
public sealed class LabelListResponse
{
/// <summary>
/// Raw label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent properties API calls
/// Replaces the generic UTorrentResponse<T> for properties retrieval
/// </summary>
public sealed class PropertiesResponse
{
/// <summary>
/// Raw properties data from the API
/// </summary>
[JsonProperty(PropertyName = "props")]
public object[]? PropertiesRaw { get; set; }
/// <summary>
/// Parsed properties as strongly-typed object
/// </summary>
[JsonIgnore]
public UTorrentProperties Properties { get; set; } = new();
}

View File

@@ -0,0 +1,40 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent list API calls
/// Replaces the generic UTorrentResponse<T> for torrent listings
/// </summary>
public sealed class TorrentListResponse
{
/// <summary>
/// µTorrent build number
/// </summary>
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
/// <summary>
/// List of torrent data from the API
/// </summary>
[JsonProperty(PropertyName = "torrents")]
public object[][]? TorrentsRaw { get; set; }
/// <summary>
/// Label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed torrents as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentItem> Torrents { get; set; } = new();
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,18 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a file within a torrent from µTorrent Web UI API
/// Based on the files array structure from the API documentation
/// </summary>
public sealed class UTorrentFile
{
public string Name { get; set; } = string.Empty;
public long Size { get; set; }
public long Downloaded { get; set; }
public int Priority { get; set; }
public int Index { get; set; }
}

View File

@@ -0,0 +1,181 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a torrent from µTorrent Web UI API
/// Based on the torrent array structure from the API documentation
/// </summary>
public sealed class UTorrentItem
{
/// <summary>
/// Torrent hash (index 0)
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Status bitfield (index 1)
/// </summary>
public int Status { get; set; }
/// <summary>
/// Torrent name (index 2)
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Total size in bytes (index 3)
/// </summary>
public long Size { get; set; }
/// <summary>
/// Progress in permille (1000 = 100%) (index 4)
/// </summary>
public int Progress { get; set; }
/// <summary>
/// Downloaded bytes (index 5)
/// </summary>
public long Downloaded { get; set; }
/// <summary>
/// Uploaded bytes (index 6)
/// </summary>
public long Uploaded { get; set; }
/// <summary>
/// Ratio * 1000 (index 7)
/// </summary>
public int RatioRaw { get; set; }
/// <summary>
/// Upload speed in bytes/sec (index 8)
/// </summary>
public int UploadSpeed { get; set; }
/// <summary>
/// Download speed in bytes/sec (index 9)
/// </summary>
public int DownloadSpeed { get; set; }
/// <summary>
/// ETA in seconds (index 10)
/// </summary>
public int ETA { get; set; }
/// <summary>
/// Label (index 11)
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Connected peers (index 12)
/// </summary>
public int PeersConnected { get; set; }
/// <summary>
/// Peers in swarm (index 13)
/// </summary>
public int PeersInSwarm { get; set; }
/// <summary>
/// Connected seeds (index 14)
/// </summary>
public int SeedsConnected { get; set; }
/// <summary>
/// Seeds in swarm (index 15)
/// </summary>
public int SeedsInSwarm { get; set; }
/// <summary>
/// Availability (index 16)
/// </summary>
public int Availability { get; set; }
/// <summary>
/// Queue order (index 17)
/// </summary>
public int QueueOrder { get; set; }
/// <summary>
/// Remaining bytes (index 18)
/// </summary>
public long Remaining { get; set; }
/// <summary>
/// Download URL (index 19)
/// </summary>
public string DownloadUrl { get; set; } = string.Empty;
/// <summary>
/// RSS feed URL (index 20)
/// </summary>
public string RssFeedUrl { get; set; } = string.Empty;
/// <summary>
/// Status message (index 21)
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Stream ID (index 22)
/// </summary>
public string StreamId { get; set; } = string.Empty;
/// <summary>
/// Date added as Unix timestamp (index 23)
/// </summary>
public long DateAdded { get; set; }
/// <summary>
/// Date completed as Unix timestamp (index 24)
/// </summary>
public long DateCompleted { get; set; }
/// <summary>
/// App update URL (index 25)
/// </summary>
public string AppUpdateUrl { get; set; } = string.Empty;
/// <summary>
/// Save path (index 26)
/// </summary>
public string SavePath { get; set; } = string.Empty;
/// <summary>
/// Calculated ratio value (RatioRaw / 1000.0)
/// </summary>
[JsonIgnore]
public double Ratio => RatioRaw / 1000.0;
/// <summary>
/// Progress as percentage (0.0 to 1.0)
/// </summary>
[JsonIgnore]
public double ProgressPercent => Progress / 1000.0;
/// <summary>
/// Date completed as DateTime (or null if not completed)
/// </summary>
[JsonIgnore]
public DateTime? DateCompletedDateTime =>
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted).DateTime : null;
/// <summary>
/// Seeding time in seconds (calculated from DateCompleted to now)
/// </summary>
[JsonIgnore]
public TimeSpan? SeedingTime
{
get
{
if (DateCompletedDateTime.HasValue)
{
return DateTime.UtcNow - DateCompletedDateTime.Value;
}
return null;
}
}
}

View File

@@ -0,0 +1,85 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents torrent properties from µTorrent Web UI API getprops action
/// Based on the properties structure from the API documentation
/// </summary>
public sealed class UTorrentProperties
{
/// <summary>
/// Torrent hash
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Trackers list (newlines are represented by \r\n)
/// </summary>
public string Trackers { get; set; } = string.Empty;
public List<string> TrackerList => Trackers
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
/// <summary>
/// Upload limit in bytes per second
/// </summary>
public int UploadLimit { get; set; }
/// <summary>
/// Download limit in bytes per second
/// </summary>
public int DownloadLimit { get; set; }
/// <summary>
/// Initial seeding / Super seeding
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SuperSeed { get; set; }
/// <summary>
/// Use DHT
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int Dht { get; set; }
/// <summary>
/// Use PEX (Peer Exchange)
/// -1 = Not allowed (indicates private torrent), 0 = Disabled, 1 = Enabled
/// </summary>
public int Pex { get; set; }
/// <summary>
/// Override queueing
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SeedOverride { get; set; }
/// <summary>
/// Seed ratio in per mils (1000 = 1.0 ratio)
/// </summary>
public int SeedRatio { get; set; }
/// <summary>
/// Seeding time in seconds
/// 0 = No minimum seeding time
/// </summary>
public int SeedTime { get; set; }
/// <summary>
/// Upload slots
/// </summary>
public int UploadSlots { get; set; }
/// <summary>
/// Whether this torrent is private (based on PEX value)
/// Private torrents have PEX = -1 (not allowed)
/// </summary>
public bool IsPrivate => Pex == -1;
/// <summary>
/// Calculated seed ratio value (SeedRatio / 1000.0)
/// </summary>
public double SeedRatioValue => SeedRatio / 1000.0;
}

View File

@@ -0,0 +1,61 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Base response wrapper for µTorrent Web UI API calls
/// </summary>
public sealed record UTorrentResponse<T>
{
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
[JsonProperty(PropertyName = "label")]
public object[][]? Labels { get; set; }
[JsonProperty(PropertyName = "torrents")]
public T? Torrents { get; set; }
[JsonProperty(PropertyName = "torrentp")]
public object[]? TorrentProperties { get; set; }
[JsonProperty(PropertyName = "files")]
public object[]? FilesDto { get; set; }
[JsonIgnore]
public List<UTorrentFile>? Files
{
get
{
if (FilesDto is null || FilesDto.Length < 2)
{
return null;
}
var files = new List<UTorrentFile>();
if (FilesDto[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
var fileTokenArray = (JArray)jToken;
var fileArray = fileTokenArray.ToObject<object[]>() ?? [];
files.Add(new UTorrentFile
{
Name = fileArray[0].ToString() ?? string.Empty,
Size = Convert.ToInt64(fileArray[1]),
Downloaded = Convert.ToInt64(fileArray[2]),
Priority = Convert.ToInt32(fileArray[3]),
});
}
}
return files;
}
}
[JsonProperty(PropertyName = "props")]
public UTorrentProperties[]? Properties { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrCommand
{
public string Name { get; set; }
public long? SeriesId { get; set; }
public long? SeasonNumber { get; set; }
public List<long>? EpisodeIds { get; set; }
public SeriesSearchType SearchType { get; set; }
}

View File

@@ -11,4 +11,5 @@ public enum DeleteReason
AllFilesSkipped, AllFilesSkipped,
AllFilesSkippedByQBit, AllFilesSkippedByQBit,
AllFilesBlocked, AllFilesBlocked,
MalwareFileFound,
} }

View File

@@ -2,7 +2,8 @@
public enum DownloadClientTypeName public enum DownloadClientTypeName
{ {
QBittorrent, qBittorrent,
Deluge, Deluge,
Transmission, Transmission,
} uTorrent,
}

View File

@@ -0,0 +1,13 @@
namespace Cleanuparr.Domain.Enums;
public enum NotificationEventType
{
Test,
FailedImportStrike,
StalledStrike,
SlowSpeedStrike,
SlowTimeStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum NotificationProviderType
{
Notifiarr,
Apprise,
Ntfy
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyAuthenticationType
{
None,
BasicAuth,
AccessToken
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyPriority
{
Min = 1,
Low = 2,
Default = 3,
High = 4,
Max = 5
}

View File

@@ -1,6 +1,6 @@
namespace Cleanuparr.Domain.Enums; namespace Cleanuparr.Domain.Enums;
public enum SonarrSearchType public enum SeriesSearchType
{ {
Episode, Episode,
Season, Season,

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions; namespace Cleanuparr.Domain.Exceptions;
public class DelugeClientException : Exception public class DelugeClientException : Exception
{ {

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions; namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLoginException : DelugeClientException public sealed class DelugeLoginException : DelugeClientException
{ {

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions; namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLogoutException : DelugeClientException public sealed class DelugeLogoutException : DelugeClientException
{ {

View File

@@ -0,0 +1,15 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent authentication fails
/// </summary>
public class UTorrentAuthenticationException : UTorrentException
{
public UTorrentAuthenticationException(string message) : base(message)
{
}
public UTorrentAuthenticationException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class UTorrentException : Exception
{
public UTorrentException(string message) : base(message)
{
}
public UTorrentException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,22 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent response parsing fails
/// </summary>
public class UTorrentParsingException : UTorrentException
{
/// <summary>
/// The raw response that failed to parse
/// </summary>
public string RawResponse { get; }
public UTorrentParsingException(string message, string rawResponse) : base(message)
{
RawResponse = rawResponse;
}
public UTorrentParsingException(string message, string rawResponse, Exception innerException) : base(message, innerException)
{
RawResponse = rawResponse;
}
}

View File

@@ -16,6 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" /> <PackageReference Include="Serilog.Expressions" Version="5.0.0" />

View File

@@ -0,0 +1,266 @@
using System.Net;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Newtonsoft.Json;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
public class UTorrentClientTests
{
private readonly UTorrentClient _client;
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
private readonly DownloadClientConfig _config;
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
private readonly Mock<IUTorrentHttpService> _mockHttpService;
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
public UTorrentClientTests()
{
_mockHttpHandler = new Mock<HttpMessageHandler>();
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
_mockHttpService = new Mock<IUTorrentHttpService>();
_mockResponseParser = new Mock<IUTorrentResponseParser>();
_mockLogger = new Mock<ILogger<UTorrentClient>>();
_config = new DownloadClientConfig
{
Name = "test",
Type = DownloadClientType.Torrent,
TypeName = DownloadClientTypeName.uTorrent,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "password"
};
_client = new UTorrentClient(
_config,
_mockAuthenticator.Object,
_mockHttpService.Object,
_mockResponseParser.Object,
_mockLogger.Object
);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
new object[] // File 1
{
"test name",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 2
{
"Dir1/Dir11/test11.zipx",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 3
{
"Dir1/sample.txt",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
}
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Equal(3, files.Count);
Assert.Equal("test name", files[0].Name);
Assert.Equal(2604L, files[0].Size);
Assert.Equal(0L, files[0].Downloaded);
Assert.Equal(2, files[0].Priority);
Assert.Equal(0, files[0].Index);
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
Assert.Equal(2604L, files[1].Size);
Assert.Equal(0L, files[1].Downloaded);
Assert.Equal(2, files[1].Priority);
Assert.Equal(1, files[1].Index);
Assert.Equal("Dir1/sample.txt", files[2].Name);
Assert.Equal(2604L, files[2].Size);
Assert.Equal(0L, files[2].Downloaded);
Assert.Equal(2, files[2].Priority);
Assert.Equal(2, files[2].Index);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = null
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
}

View File

@@ -1,8 +1,8 @@
using Cleanuparr.Infrastructure.Features.ContentBlocker; using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker; namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
public class FilenameEvaluatorFixture public class FilenameEvaluatorFixture
{ {

View File

@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Enums;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker; namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture> public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture>
{ {

View File

@@ -7,20 +7,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Features\" /> <PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" /> <PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" /> <PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" /> <PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" 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="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" /> <PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.14.0" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -11,15 +11,15 @@ namespace Cleanuparr.Infrastructure.Events;
/// </summary> /// </summary>
public class EventCleanupService : BackgroundService public class EventCleanupService : BackgroundService
{ {
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<EventCleanupService> _logger; private readonly ILogger<EventCleanupService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
private readonly int _retentionDays = 30; // Keep events for 30 days private readonly int _retentionDays = 30; // Keep events for 30 days
public EventCleanupService(IServiceProvider serviceProvider, ILogger<EventCleanupService> logger) public EventCleanupService(ILogger<EventCleanupService> logger, IServiceScopeFactory scopeFactory)
{ {
_serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
_scopeFactory = scopeFactory;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -58,7 +58,7 @@ public class EventCleanupService : BackgroundService
{ {
try try
{ {
using var scope = _serviceProvider.CreateScope(); await using var scope = _scopeFactory.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>(); var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);

View File

@@ -1,12 +1,14 @@
using System.Dynamic;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence; using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events; using Cleanuparr.Persistence.Models.Events;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Notifications;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -47,7 +49,10 @@ public class EventPublisher
EventType = eventType, EventType = eventType,
Message = message, Message = message,
Severity = severity, Severity = severity,
Data = data != null ? JsonSerializer.Serialize(data) : null, Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
}) : null,
TrackingId = trackingId TrackingId = trackingId
}; };
@@ -73,14 +78,40 @@ public class EventPublisher
StrikeType.FailedImport => EventType.FailedImportStrike, StrikeType.FailedImport => EventType.FailedImportStrike,
StrikeType.SlowSpeed => EventType.SlowSpeedStrike, StrikeType.SlowSpeed => EventType.SlowSpeedStrike,
StrikeType.SlowTime => EventType.SlowTimeStrike, StrikeType.SlowTime => EventType.SlowTimeStrike,
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
}; };
dynamic data;
if (strikeType is StrikeType.FailedImport)
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
data = new
{
hash,
itemName,
strikeCount,
strikeType,
failedImportReasons = record.StatusMessages ?? [],
};
}
else
{
data = new
{
hash,
itemName,
strikeCount,
strikeType,
};
}
// Publish the event // Publish the event
await PublishAsync( await PublishAsync(
eventType, eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'", $"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important, EventSeverity.Important,
data: new { hash, itemName, strikeCount, strikeType }); data: data);
// Send notification (uses ContextProvider internally) // Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyStrike(strikeType, strikeCount); await _notificationPublisher.NotifyStrike(strikeType, strikeCount);

View File

@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities.Deluge.Response; using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Extensions; namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using QBittorrent.Client; using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Extensions; namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Transmission.API.RPC.Entity; using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Extensions; namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,13 +1,13 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers; using Cleanuparr.Shared.Helpers;
using Data.Models.Arr; using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -65,7 +65,7 @@ public abstract class ArrClient : IArrClient
return queueResponse; return queueResponse;
} }
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes) public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
{ {
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(); var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
@@ -105,6 +105,12 @@ public abstract class ArrClient : IArrClient
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : queueCleanerConfig.FailedImport.MaxStrikes; ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : queueCleanerConfig.FailedImport.MaxStrikes;
_logger.LogInformation(
"Item {title} has failed import status with the following reason(s):\n{messages}",
record.Title,
string.Join("\n", record.StatusMessages?.Select(JsonConvert.SerializeObject) ?? [])
);
return await _striker.StrikeAndCheckLimit( return await _striker.StrikeAndCheckLimit(
record.DownloadId, record.DownloadId,
record.Title, record.Title,
@@ -206,7 +212,7 @@ public abstract class ArrClient : IArrClient
return response; return response;
} }
private bool HasIgnoredPatterns(QueueRecord record) private static bool HasIgnoredPatterns(QueueRecord record)
{ {
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(); var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();

View File

@@ -8,16 +8,22 @@ public sealed class ArrClientFactory
private readonly ISonarrClient _sonarrClient; private readonly ISonarrClient _sonarrClient;
private readonly IRadarrClient _radarrClient; private readonly IRadarrClient _radarrClient;
private readonly ILidarrClient _lidarrClient; private readonly ILidarrClient _lidarrClient;
private readonly IReadarrClient _readarrClient;
private readonly IWhisparrClient _whisparrClient;
public ArrClientFactory( public ArrClientFactory(
SonarrClient sonarrClient, SonarrClient sonarrClient,
RadarrClient radarrClient, RadarrClient radarrClient,
LidarrClient lidarrClient LidarrClient lidarrClient,
ReadarrClient readarrClient,
WhisparrClient whisparrClient
) )
{ {
_sonarrClient = sonarrClient; _sonarrClient = sonarrClient;
_radarrClient = radarrClient; _radarrClient = radarrClient;
_lidarrClient = lidarrClient; _lidarrClient = lidarrClient;
_readarrClient = readarrClient;
_whisparrClient = whisparrClient;
} }
public IArrClient GetClient(InstanceType type) => public IArrClient GetClient(InstanceType type) =>
@@ -26,6 +32,8 @@ public sealed class ArrClientFactory
InstanceType.Sonarr => _sonarrClient, InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient, InstanceType.Radarr => _radarrClient,
InstanceType.Lidarr => _lidarrClient, InstanceType.Lidarr => _lidarrClient,
InstanceType.Readarr => _readarrClient,
InstanceType.Whisparr => _whisparrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported") _ => throw new NotImplementedException($"instance type {type} is not yet supported")
}; };
} }

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr.Queue;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Arr; namespace Cleanuparr.Infrastructure.Features.Arr;

Some files were not shown because too many files have changed in this diff Show More