mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-31 09:58:53 -05:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64bb9fc513 | ||
|
|
0a6ec21c95 | ||
|
|
74c49f041d | ||
|
|
f35abdefe5 | ||
|
|
43a11f0e4c | ||
|
|
a5a54e324d | ||
|
|
53adb6c1c1 | ||
|
|
a0c8ff72fb | ||
|
|
599242aa2a | ||
|
|
3e0913b437 | ||
|
|
54cabd98b4 | ||
|
|
cbc5c571b3 | ||
|
|
beea640d49 | ||
|
|
65b8a7f988 | ||
|
|
b86173d6c0 | ||
|
|
09c4b2a28e | ||
|
|
6e8545e4ca | ||
|
|
e0a6c7842b | ||
|
|
b323cb40ae | ||
|
|
95a35f9988 | ||
|
|
a7f3bad191 | ||
|
|
ed20b67b92 | ||
|
|
42a3f75d94 | ||
|
|
6de882d12e | ||
|
|
6587014e8d | ||
|
|
36a07b251a | ||
|
|
c48eed7f77 | ||
|
|
48f3c3b35b | ||
|
|
0d6f62dd70 | ||
|
|
77cc5c99ed | ||
|
|
513134fd65 | ||
|
|
906be45758 | ||
|
|
c4a15e77e4 | ||
|
|
baffdfdd5a | ||
|
|
827afb5a4d | ||
|
|
26939b2cd3 | ||
|
|
34d05c5416 | ||
|
|
b9376e02fa | ||
|
|
2d5c59ddba | ||
|
|
11864617de | ||
|
|
d5bff76a62 |
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@@ -8,4 +8,5 @@ jobs:
|
||||
with:
|
||||
dockerRepository: flaminel/cleanuperr
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
outputName: cleanuperr
|
||||
secrets: inherit
|
||||
7
.github/workflows/pipeline.yml
vendored
7
.github/workflows/pipeline.yml
vendored
@@ -1,8 +1,13 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
# paths:
|
||||
# - 'code/**'
|
||||
# branches: [ main ]
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
83
.github/workflows/release.yml
vendored
83
.github/workflows/release.yml
vendored
@@ -1,78 +1,11 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version number'
|
||||
required: true
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore code/Executable/Executable.csproj
|
||||
|
||||
- name: Build win-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime win-x64 --self-contained -o artifacts/cleanuperr-win /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build linux-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/cleanuperr-linux /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build osx-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/cleanuperr-osx /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Zip the Win release
|
||||
run: zip ./artifacts/cleanuperr-win.zip ./artifacts/cleanuperr-win/cleanuperr.exe ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Zip the Linux release
|
||||
run: zip ./artifacts/cleanuperr-linux.zip ./artifacts/cleanuperr-linux/cleanuperr ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Zip the OSX release
|
||||
run: zip ./artifacts/cleanuperr-osx.zip ./artifacts/cleanuperr-osx/cleanuperr ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.1.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
release_name: Release ${{ github.event.inputs.version }}
|
||||
draft: false
|
||||
|
||||
- name: Upload cleanuperr-win
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-win.zip
|
||||
asset_name: cleanuperr-win.zip
|
||||
asset_content_type: application/exe
|
||||
|
||||
- name: Upload cleanuperr-linux
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-linux.zip
|
||||
asset_name: cleanuperr-linux.zip
|
||||
asset_content_type: application/exe
|
||||
|
||||
- name: Upload cleanuperr-osx
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-osx.zip
|
||||
asset_name: cleanuperr-osx.zip
|
||||
asset_content_type: application/exe
|
||||
release:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.release.yml@main
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
secrets: inherit
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -165,3 +165,10 @@ src/.idea/
|
||||
|
||||
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||
.idea/
|
||||
|
||||
**/logs/
|
||||
**/MediaCover/
|
||||
**/archive/
|
||||
**/Backups/
|
||||
*.fastresume
|
||||
*.bak
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Flaminel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
324
README.md
324
README.md
@@ -1,143 +1,215 @@
|
||||
# cleanuperr
|
||||
|
||||
cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, cleanuperr can also trigger a search to replace the deleted shows/movies.
|
||||
|
||||
cleanuperr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made cleanuperr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
The tool supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
|
||||
|
||||
Refer to the [Environment variables](#Environment-variables) section for detailed configuration instructions and the [Setup](#Setup) section for an in-depth explanation of the cleanup process.
|
||||
|
||||
## Key features
|
||||
- Marks unwanted files as skip/unwanted in the download client.
|
||||
- Automatically strikes stalled or stuck downloads.
|
||||
- Removes and blocks downloads that reached the maximum number of strikes or are marked as unwanted by the download client or by cleanuperr and triggers a search for removed downloads.
|
||||
|
||||
## Important note
|
||||
|
||||
Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version:
|
||||
- qBittorrent
|
||||
- Deluge
|
||||
- Transmission
|
||||
- Sonarr
|
||||
- Radarr
|
||||
|
||||
This tool is actively developed and still a work in progress. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together:
|
||||
|
||||
> https://discord.gg/cJYPs9Bt
|
||||
|
||||
# How it works
|
||||
|
||||
1. **Content blocker** will:
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Find the corresponding item from the download client for each queue item.
|
||||
- Mark the files that were found in the queue as **unwanted/skipped** if:
|
||||
- They **are listed in the blacklist**, or
|
||||
- They **are not included in the whitelist**.
|
||||
2. **Queue cleaner** will:
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in matadata downloading** or **failed to be imported**.
|
||||
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
|
||||
- Check each queue item if it meets one of the following condition in the download client:
|
||||
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
|
||||
- All associated files of are marked as **unwanted/skipped**.
|
||||
- If the item **DOES NOT** match the above criteria, it will be skipped.
|
||||
- If the item **DOES** match the criteria or has received the **maximum number of strikes**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- A new search will be triggered for the *arr item.
|
||||
|
||||
# Setup
|
||||
|
||||
## Using qBittorrent's built-in feature (works only with qBittorrent)
|
||||
|
||||
1. Go to qBittorrent -> Options -> Downloads -> make sure `Excluded file names` is checked -> Paste an exclusion list that you have copied.
|
||||
- [blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), or
|
||||
- [permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive), or
|
||||
- create your own
|
||||
2. qBittorrent will block files from being downloaded. In the case of malicious content, **nothing is downloaded and the torrent is marked as complete**.
|
||||
3. Start **cleanuperr** with `QUEUECLEANER__ENABLED` set to `true`.
|
||||
4. The **queue cleaner** will perform a cleanup process as described in the [How it works](#how-it-works) section.
|
||||
|
||||
## Using cleanuperr's blocklist (works with all supported download clients)
|
||||
|
||||
1. Set both `QUEUECLEANER_ENABLED` and `CONTENTBLOCKER_ENABLED` to `true` in your environment variables.
|
||||
2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Environment variables](#Environment-variables) section.
|
||||
3. Once configured, cleanuperr will perform the following tasks:
|
||||
- Execute the **content blocker** job, as explained in the [How it works](#how-it-works) section.
|
||||
- Execute the **queue cleaner** job, as explained in the [How it works](#how-it-works) section.
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker compose yaml
|
||||
|
||||
```
|
||||
docker run \
|
||||
-e QuartzConfig__BlockedTorrentTrigger="0 0/10 * * * ?" \
|
||||
-e QBitConfig__Url="http://localhost:8080" \
|
||||
-e QBitConfig__Username="user" \
|
||||
-e QBitConfig__Password="pass" \
|
||||
-e SonarrConfig__Instances__0__Url="http://localhost:8989" \
|
||||
-e SonarrConfig__Instances__0__ApiKey="secret1" \
|
||||
-e SonarrConfig__Instances__1__Url="http://localhost:8990" \
|
||||
-e SonarrConfig__Instances__1__ApiKey="secret2" \
|
||||
...
|
||||
flaminel/cleanuperr:latest
|
||||
version: "3.3"
|
||||
services:
|
||||
cleanuperr:
|
||||
volumes:
|
||||
- ./cleanuperr/logs:/var/logs
|
||||
environment:
|
||||
- LOGGING__LOGLEVEL=Information
|
||||
- LOGGING__FILE__ENABLED=false
|
||||
- LOGGING__FILE__PATH=/var/logs/
|
||||
- LOGGING__ENHANCED=true
|
||||
|
||||
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
|
||||
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
|
||||
|
||||
- QUEUECLEANER__ENABLED=true
|
||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||
|
||||
- CONTENTBLOCKER__ENABLED=true
|
||||
- CONTENTBLOCKER__BLACKLIST__ENABLED=true
|
||||
- CONTENTBLOCKER__BLACKLIST__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
|
||||
# OR
|
||||
# - CONTENTBLOCKER__WHITELIST__ENABLED=true
|
||||
# - CONTENTBLOCKER__BLACKLIST__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist
|
||||
|
||||
- DOWNLOAD_CLIENT=qBittorrent
|
||||
- QBITTORRENT__URL=http://localhost:8080
|
||||
- QBITTORRENT__USERNAME=user
|
||||
- QBITTORRENT__PASSWORD=pass
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=deluge
|
||||
# - DELUGE__URL=http://localhost:8112
|
||||
# - DELUGE__PASSWORD=testing
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=transmission
|
||||
# - TRANSMISSION__URL=http://localhost:9091
|
||||
# - TRANSMISSION__USERNAME=test
|
||||
# - TRANSMISSION__PASSWORD=testing
|
||||
|
||||
- SONARR__ENABLED=true
|
||||
- SONARR__SEARCHTYPE=Episode
|
||||
- SONARR__STALLED_MAX_STRIKES=5
|
||||
- SONARR__INSTANCES__0__URL=http://localhost:8989
|
||||
- SONARR__INSTANCES__0__APIKEY=secret1
|
||||
- SONARR__INSTANCES__1__URL=http://localhost:8990
|
||||
- SONARR__INSTANCES__1__APIKEY=secret2
|
||||
|
||||
- RADARR__ENABLED=true
|
||||
- RADARR__STALLED_MAX_STRIKES=5
|
||||
- RADARR__INSTANCES__0__URL=http://localhost:7878
|
||||
- RADARR__INSTANCES__0__APIKEY=secret3
|
||||
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
||||
- RADARR__INSTANCES__1__APIKEY=secret4
|
||||
image: ghcr.io/flmorg/cleanuperr:latest
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
### Environment variables
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| QuartzConfig__BlockedTorrentTrigger | No | Quartz cron trigger | 0 0/5 * * * ? |
|
||||
| QBitConfig__Url | Yes | qBittorrent instance url | http://localhost:8080 |
|
||||
| QBitConfig__Username | Yes | qBittorrent user | empty |
|
||||
| QBitConfig__Password | Yes | qBittorrent password | empty |
|
||||
| SonarrConfig__Instances__0__Url | Yes | First Sonarr instance url | http://localhost:8989 |
|
||||
| SonarrConfig__Instances__0__ApiKey | Yes | First Sonarr instance API key | empty |
|
||||
| LOGGING__LOGLEVEL | No | Can be `Verbose`, `Debug`, `Information`, `Warning`, `Error` or `Fatal` | `Information` |
|
||||
| LOGGING__FILE__ENABLED | No | Enable or disable logging to file | false |
|
||||
| LOGGING__FILE__PATH | No | Directory where to save the log files | empty |
|
||||
| LOGGING__ENHANCED | No | Enhance logs whenever possible<br>A more detailed description is provided [here](variables.md#LOGGING__ENHANCED) | true |
|
||||
|||||
|
||||
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html)<br>Can be a max of 1h interval | 0 0/5 * * * ? |
|
||||
| TRIGGERS__CONTENTBLOCKER | Yes if content blocker is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html)<br>Can be a max of 1h interval | 0 0/5 * * * ? |
|
||||
|||||
|
||||
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner | true |
|
||||
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process | true |
|
||||
| QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | After how many strikes should a failed import be removed<br>0 means never | 0 |
|
||||
| QUEUECLEANER__STALLED_MAX_STRIKES | No | After how many strikes should a stalled download be removed<br>0 means never | 0 |
|
||||
|||||
|
||||
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker | false |
|
||||
| CONTENTBLOCKER__BLACKLIST__ENABLED | Yes if content blocker is enabled and whitelist is not enabled | Enable or disable the blacklist | false |
|
||||
| CONTENTBLOCKER__BLACKLIST__PATH | Yes if blacklist is enabled | Path to the blacklist (local file or url)<br>Needs to be json compatible | empty |
|
||||
| CONTENTBLOCKER__WHITELIST__ENABLED | Yes if content blocker is enabled and blacklist is not enabled | Enable or disable the whitelist | false |
|
||||
| CONTENTBLOCKER__BLACKLIST__PATH | Yes if whitelist is enabled | Path to the whitelist (local file or url)<br>Needs to be json compatible | empty |
|
||||
|||||
|
||||
| DOWNLOAD_CLIENT | No | Download client that is used by *arrs<br>Can be `qbittorrent`, `deluge` or `transmission` | `qbittorrent` |
|
||||
| QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 |
|
||||
| QBITTORRENT__USERNAME | No | qBittorrent user | empty |
|
||||
| QBITTORRENT__PASSWORD | No | qBittorrent password | empty |
|
||||
|||||
|
||||
| DELUGE__URL | No | Deluge instance url | http://localhost:8080 |
|
||||
| DELUGE__PASSWORD | No | Deluge password | empty |
|
||||
|||||
|
||||
| TRANSMISSION__URL | No | Transmission instance url | http://localhost:9091 |
|
||||
| TRANSMISSION__USERNAME | No | Transmission user | empty |
|
||||
| TRANSMISSION__PASSWORD | No | Transmission password | empty |
|
||||
|||||
|
||||
| SONARR__ENABLED | No | Enable or disable Sonarr cleanup | true |
|
||||
| SONARR__SEARCHTYPE | No | What to search for after removing a queue item<br>Can be `Episode`, `Season` or `Series` | `Episode` |
|
||||
| SONARR__INSTANCES__0__URL | No | First Sonarr instance url | http://localhost:8989 |
|
||||
| SONARR__INSTANCES__0__APIKEY | No | First Sonarr instance API key | empty |
|
||||
|||||
|
||||
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
|
||||
| RADARR__INSTANCES__0__URL | No | First Radarr instance url | http://localhost:8989 |
|
||||
| RADARR__INSTANCES__0__APIKEY | No | First Radarr instance API key | empty |
|
||||
|
||||
#
|
||||
### To be noted
|
||||
|
||||
1. The blacklist and the whitelist can not be both enabled at the same time.
|
||||
2. The queue cleaner and content blocker can be enabled or disabled separately, if you want to run only one of them.
|
||||
3. Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of cleanuperr.
|
||||
4. The blocklists (blacklist/whitelist) should have a single pattern on each line and supports the following:
|
||||
```
|
||||
*example // file name ends with "example"
|
||||
example* // file name starts with "example"
|
||||
*example* // file name has "example" in the name
|
||||
example // file name is exactly the word "example"
|
||||
regex:<ANY_REGEX> // regex that needs to be marked at the start of the line with "regex:"
|
||||
```
|
||||
5. Multiple Sonarr/Radarr instances can be specified using this format, where `<NUMBER>` starts from 0:
|
||||
```
|
||||
SONARR__INSTANCES__<NUMBER>__URL
|
||||
SONARR__INSTANCES__<NUMBER>__APIKEY
|
||||
```
|
||||
|
||||
#
|
||||
|
||||
Multiple Sonarr instances can be specified using this format:
|
||||
### Binaries (if you're not using Docker)
|
||||
|
||||
```
|
||||
SonarrConfig__Instances__<NUMBER>__Url
|
||||
SonarrConfig__Instances__<NUMBER>__ApiKey
|
||||
```
|
||||
1. Download the binaries from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract them from the zip file.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [above](#environment-variables).
|
||||
|
||||
where `<NUMBER>` starts from 0.
|
||||
### Run as a Windows Service
|
||||
|
||||
## How it works
|
||||
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678
|
||||
|
||||
1. Add excluded file names to prevent malicious files from being downloaded by qBittorrent.
|
||||
2. cleanuperr goes through all items in Sonarr's queue every at every 5th minute.
|
||||
3. For each queue item, a call is made to qBittorrent to get the stats of the torrent.
|
||||
4. If a torrent is found to be marked as completed, but with 0 downloaded bytes, cleanuperr calls Sonarr to add that torrent to the blocklist.
|
||||
5. If any malicious torrents have been found, cleanuperr calls Sonarr to automatically search again.
|
||||
|
||||
<details>
|
||||
<summary>Extensions to block</summary>
|
||||
<pre><code>*.apk
|
||||
*.bat
|
||||
*.bin
|
||||
*.bmp
|
||||
*.cmd
|
||||
*.com
|
||||
*.db
|
||||
*.diz
|
||||
*.dll
|
||||
*.dmg
|
||||
*.etc
|
||||
*.exe
|
||||
*.gif
|
||||
*.htm
|
||||
*.html
|
||||
*.ico
|
||||
*.ini
|
||||
*.iso
|
||||
*.jar
|
||||
*.jpg
|
||||
*.js
|
||||
*.link
|
||||
*.lnk
|
||||
*.msi
|
||||
*.nfo
|
||||
*.perl
|
||||
*.php
|
||||
*.pl
|
||||
*.png
|
||||
*.ps1
|
||||
*.psc1
|
||||
*.psd1
|
||||
*.psm1
|
||||
*.py
|
||||
*.pyd
|
||||
*.rb
|
||||
*.readme
|
||||
*.reg
|
||||
*.run
|
||||
*.scr
|
||||
*.sh
|
||||
*.sql
|
||||
*.text
|
||||
*.thumb
|
||||
*.torrent
|
||||
*.txt
|
||||
*.url
|
||||
*.vbs
|
||||
*.wsf
|
||||
*.xml
|
||||
*.zipx
|
||||
*.7z
|
||||
*.bdjo
|
||||
*.bdmv
|
||||
*.bin
|
||||
*.bmp
|
||||
*.cci
|
||||
*.clpi
|
||||
*.crt
|
||||
*.dll
|
||||
*.exe
|
||||
*.html
|
||||
*.idx
|
||||
*.inf
|
||||
*.jar
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.lnk
|
||||
*.m4a
|
||||
*.mpls
|
||||
*.msi
|
||||
*.nfo
|
||||
*.pdf
|
||||
*.png
|
||||
*.rar
|
||||
*(sample).*
|
||||
*sample.mkv
|
||||
*sample.mp4
|
||||
*.sfv
|
||||
*.srt
|
||||
*.sub
|
||||
*.tbl
|
||||
Trailer.*
|
||||
*.txt
|
||||
*.url
|
||||
*.xig
|
||||
*.xml
|
||||
*.xrt
|
||||
*.zip
|
||||
*.zipx
|
||||
*.Lnk
|
||||
</code></pre>
|
||||
</details>
|
||||
## Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
- [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr)
|
||||
- [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner)
|
||||
519
blacklist
Normal file
519
blacklist
Normal file
@@ -0,0 +1,519 @@
|
||||
*(sample).*
|
||||
*.0xe
|
||||
*.73k
|
||||
*.73p
|
||||
*.7z
|
||||
*.89k
|
||||
*.89z
|
||||
*.8ck
|
||||
*.a7r
|
||||
*.ac
|
||||
*.acc
|
||||
*.ace
|
||||
*.acr
|
||||
*.actc
|
||||
*.action
|
||||
*.actm
|
||||
*.ade
|
||||
*.adp
|
||||
*.afmacro
|
||||
*.afmacros
|
||||
*.ahk
|
||||
*.ai
|
||||
*.aif
|
||||
*.air
|
||||
*.alz
|
||||
*.api
|
||||
*.apk
|
||||
*.app
|
||||
*.appimage
|
||||
*.applescript
|
||||
*.application
|
||||
*.appx
|
||||
*.arc
|
||||
*.arj
|
||||
*.arscript
|
||||
*.asb
|
||||
*.asp
|
||||
*.aspx
|
||||
*.aspx-exe
|
||||
*.atmx
|
||||
*.azw2
|
||||
*.ba_
|
||||
*.bak
|
||||
*.bas
|
||||
*.bash
|
||||
*.bat
|
||||
*.bdjo
|
||||
*.bdmv
|
||||
*.beam
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bms
|
||||
*.bns
|
||||
*.bsa
|
||||
*.btm
|
||||
*.bz2
|
||||
*.c
|
||||
*.cab
|
||||
*.caction
|
||||
*.cci
|
||||
*.cda
|
||||
*.cdb
|
||||
*.cel
|
||||
*.celx
|
||||
*.cfs
|
||||
*.cgi
|
||||
*.cheat
|
||||
*.chm
|
||||
*.ckpt
|
||||
*.cla
|
||||
*.class
|
||||
*.clpi
|
||||
*.cmd
|
||||
*.cof
|
||||
*.coffee
|
||||
*.com
|
||||
*.command
|
||||
*.conf
|
||||
*.config
|
||||
*.cpl
|
||||
*.crt
|
||||
*.cs
|
||||
*.csh
|
||||
*.csharp
|
||||
*.csproj
|
||||
*.css
|
||||
*.csv
|
||||
*.cue
|
||||
*.cur
|
||||
*.cyw
|
||||
*.daemon
|
||||
*.dat
|
||||
*.data-00000-of-00001
|
||||
*.db
|
||||
*.deamon
|
||||
*.deb
|
||||
*.dek
|
||||
*.diz
|
||||
*.dld
|
||||
*.dll
|
||||
*.dmc
|
||||
*.dmg
|
||||
*.doc
|
||||
*.docb
|
||||
*.docm
|
||||
*.docx
|
||||
*.dot
|
||||
*.dotb
|
||||
*.dotm
|
||||
*.drv
|
||||
*.ds
|
||||
*.dw
|
||||
*.dword
|
||||
*.dxl
|
||||
*.e_e
|
||||
*.ear
|
||||
*.ebacmd
|
||||
*.ebm
|
||||
*.ebs
|
||||
*.ebs2
|
||||
*.ecf
|
||||
*.eham
|
||||
*.elf
|
||||
*.elf-so
|
||||
*.email
|
||||
*.emu
|
||||
*.epk
|
||||
*.es
|
||||
*.esh
|
||||
*.etc
|
||||
*.ex4
|
||||
*.ex5
|
||||
*.ex_
|
||||
*.exe
|
||||
*.exe-only
|
||||
*.exe-service
|
||||
*.exe-small
|
||||
*.exe1
|
||||
*.exopc
|
||||
*.exz
|
||||
*.ezs
|
||||
*.ezt
|
||||
*.fas
|
||||
*.fba
|
||||
*.fky
|
||||
*.flac
|
||||
*.flatpak
|
||||
*.flv
|
||||
*.fpi
|
||||
*.frs
|
||||
*.fxp
|
||||
*.gadget
|
||||
*.gat
|
||||
*.gif
|
||||
*.gifv
|
||||
*.gm9
|
||||
*.gpe
|
||||
*.gpu
|
||||
*.gs
|
||||
*.gz
|
||||
*.h5
|
||||
*.ham
|
||||
*.hex
|
||||
*.hlp
|
||||
*.hms
|
||||
*.hpf
|
||||
*.hta
|
||||
*.hta-psh
|
||||
*.htaccess
|
||||
*.htm
|
||||
*.html
|
||||
*.icd
|
||||
*.icns
|
||||
*.ico
|
||||
*.idx
|
||||
*.iim
|
||||
*.img
|
||||
*.index
|
||||
*.inf
|
||||
*.ini
|
||||
*.ink
|
||||
*.ins
|
||||
*.ipa
|
||||
*.ipf
|
||||
*.ipk
|
||||
*.ipsw
|
||||
*.iqylink
|
||||
*.iso
|
||||
*.isp
|
||||
*.isu
|
||||
*.ita
|
||||
*.izh
|
||||
*.izma ace
|
||||
*.jar
|
||||
*.java
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.js
|
||||
*.js_be
|
||||
*.js_le
|
||||
*.jse
|
||||
*.jsf
|
||||
*.json
|
||||
*.jsp
|
||||
*.jsx
|
||||
*.kix
|
||||
*.ksh
|
||||
*.kx
|
||||
*.lck
|
||||
*.ldb
|
||||
*.lib
|
||||
*.link
|
||||
*.lnk
|
||||
*.lo
|
||||
*.lock
|
||||
*.log
|
||||
*.loop-vbs
|
||||
*.ls
|
||||
*.m3u
|
||||
*.m4a
|
||||
*.mac
|
||||
*.macho
|
||||
*.mamc
|
||||
*.manifest
|
||||
*.mcr
|
||||
*.md
|
||||
*.mda
|
||||
*.mdb
|
||||
*.mde
|
||||
*.mdf
|
||||
*.mdn
|
||||
*.mdt
|
||||
*.mel
|
||||
*.mem
|
||||
*.meta
|
||||
*.mgm
|
||||
*.mhm
|
||||
*.mht
|
||||
*.mhtml
|
||||
*.mid
|
||||
*.mio
|
||||
*.mlappinstall
|
||||
*.mlx
|
||||
*.mm
|
||||
*.mobileconfig
|
||||
*.model
|
||||
*.moo
|
||||
*.mp3
|
||||
*.mpa
|
||||
*.mpk
|
||||
*.mpls
|
||||
*.mrc
|
||||
*.mrp
|
||||
*.ms
|
||||
*.msc
|
||||
*.msh
|
||||
*.msh1
|
||||
*.msh1xml
|
||||
*.msh2
|
||||
*.msh2xml
|
||||
*.mshxml
|
||||
*.msi
|
||||
*.msi-nouac
|
||||
*.msix
|
||||
*.msl
|
||||
*.msp
|
||||
*.mst
|
||||
*.msu
|
||||
*.mxe
|
||||
*.n
|
||||
*.ncl
|
||||
*.net
|
||||
*.nexe
|
||||
*.nfo
|
||||
*.nrg
|
||||
*.num
|
||||
*.nzb.bz2
|
||||
*.nzb.gz
|
||||
*.nzbs
|
||||
*.ocx
|
||||
*.odt
|
||||
*.ore
|
||||
*.ost
|
||||
*.osx
|
||||
*.osx-app
|
||||
*.otm
|
||||
*.out
|
||||
*.ova
|
||||
*.p
|
||||
*.paf
|
||||
*.pak
|
||||
*.pb
|
||||
*.pcd
|
||||
*.pdb
|
||||
*.pdf
|
||||
*.pea
|
||||
*.perl
|
||||
*.pex
|
||||
*.phar
|
||||
*.php
|
||||
*.php5
|
||||
*.pif
|
||||
*.pkg
|
||||
*.pl
|
||||
*.plsc
|
||||
*.plx
|
||||
*.png
|
||||
*.pol
|
||||
*.pot
|
||||
*.potm
|
||||
*.powershell
|
||||
*.ppam
|
||||
*.ppkg
|
||||
*.pps
|
||||
*.ppsm
|
||||
*.ppt
|
||||
*.pptm
|
||||
*.pptx
|
||||
*.prc
|
||||
*.prg
|
||||
*.ps
|
||||
*.ps1
|
||||
*.ps1xml
|
||||
*.ps2
|
||||
*.ps2xml
|
||||
*.psc1
|
||||
*.psc2
|
||||
*.psd
|
||||
*.psd1
|
||||
*.psh
|
||||
*.psh-cmd
|
||||
*.psh-net
|
||||
*.psh-reflection
|
||||
*.psm1
|
||||
*.pst
|
||||
*.pt
|
||||
*.pvd
|
||||
*.pwc
|
||||
*.pxo
|
||||
*.py
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.pyo
|
||||
*.python
|
||||
*.pyz
|
||||
*.qit
|
||||
*.qpx
|
||||
*.ram
|
||||
*.rar
|
||||
*.raw
|
||||
*.rb
|
||||
*.rbf
|
||||
*.rbx
|
||||
*.readme
|
||||
*.reg
|
||||
*.resources
|
||||
*.resx
|
||||
*.rfs
|
||||
*.rfu
|
||||
*.rgs
|
||||
*.rm
|
||||
*.rox
|
||||
*.rpg
|
||||
*.rpj
|
||||
*.rpm
|
||||
*.ruby
|
||||
*.run
|
||||
*.rxe
|
||||
*.s2a
|
||||
*.sample
|
||||
*.sapk
|
||||
*.savedmodel
|
||||
*.sbs
|
||||
*.sca
|
||||
*.scar
|
||||
*.scb
|
||||
*.scf
|
||||
*.scpt
|
||||
*.scptd
|
||||
*.scr
|
||||
*.script
|
||||
*.sct
|
||||
*.seed
|
||||
*.server
|
||||
*.service
|
||||
*.sfv
|
||||
*.sh
|
||||
*.shb
|
||||
*.shell
|
||||
*.shortcut
|
||||
*.shs
|
||||
*.shtml
|
||||
*.sit
|
||||
*.sitx
|
||||
*.sk
|
||||
*.sldm
|
||||
*.sln
|
||||
*.smm
|
||||
*.snap
|
||||
*.snd
|
||||
*.spr
|
||||
*.sql
|
||||
*.sqx
|
||||
*.srec
|
||||
*.srt
|
||||
*.ssm
|
||||
*.sts
|
||||
*.sub
|
||||
*.svg
|
||||
*.swf
|
||||
*.sys
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tbl
|
||||
*.tbz
|
||||
*.tcp
|
||||
*.text
|
||||
*.tf
|
||||
*.tgz
|
||||
*.thm
|
||||
*.thmx
|
||||
*.thumb
|
||||
*.tiapp
|
||||
*.tif
|
||||
*.tiff
|
||||
*.tipa
|
||||
*.tmp
|
||||
*.tms
|
||||
*.toast
|
||||
*.torrent
|
||||
*.tpk
|
||||
*.txt
|
||||
*.u3p
|
||||
*.udf
|
||||
*.upk
|
||||
*.upx
|
||||
*.url
|
||||
*.uvm
|
||||
*.uw8
|
||||
*.vb
|
||||
*.vba
|
||||
*.vba-exe
|
||||
*.vba-psh
|
||||
*.vbapplication
|
||||
*.vbe
|
||||
*.vbs
|
||||
*.vbscript
|
||||
*.vbscript
|
||||
*.vcd
|
||||
*.vdo
|
||||
*.vexe
|
||||
*.vhd
|
||||
*.vhdx
|
||||
*.vlx
|
||||
*.vm
|
||||
*.vmdk
|
||||
*.vob
|
||||
*.vocab
|
||||
*.vpm
|
||||
*.vxp
|
||||
*.war
|
||||
*.wav
|
||||
*.wbk
|
||||
*.wcm
|
||||
*.webm
|
||||
*.widget
|
||||
*.wim
|
||||
*.wiz
|
||||
*.wma
|
||||
*.workflow
|
||||
*.wpk
|
||||
*.wpl
|
||||
*.wpm
|
||||
*.wps
|
||||
*.ws
|
||||
*.wsc
|
||||
*.wsf
|
||||
*.wsh
|
||||
*.x86
|
||||
*.x86_64
|
||||
*.xaml
|
||||
*.xap
|
||||
*.xbap
|
||||
*.xbe
|
||||
*.xex
|
||||
*.xig
|
||||
*.xla
|
||||
*.xlam
|
||||
*.xll
|
||||
*.xlm
|
||||
*.xls
|
||||
*.xlsb
|
||||
*.xlsm
|
||||
*.xlsx
|
||||
*.xlt
|
||||
*.xltb
|
||||
*.xltm
|
||||
*.xlw
|
||||
*.xml
|
||||
*.xqt
|
||||
*.xrt
|
||||
*.xys
|
||||
*.xz
|
||||
*.ygh
|
||||
*.z
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zl9
|
||||
*.zoo
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
Trailer.*
|
||||
VOSTFR
|
||||
api
|
||||
51
blacklist_permissive
Normal file
51
blacklist_permissive
Normal file
@@ -0,0 +1,51 @@
|
||||
*.apk
|
||||
*.bat
|
||||
*.bin
|
||||
*.bmp
|
||||
*.cmd
|
||||
*.com
|
||||
*.db
|
||||
*.diz
|
||||
*.dll
|
||||
*.dmg
|
||||
*.etc
|
||||
*.exe
|
||||
*.gif
|
||||
*.htm
|
||||
*.html
|
||||
*.ico
|
||||
*.ini
|
||||
*.iso
|
||||
*.jar
|
||||
*.jpg
|
||||
*.js
|
||||
*.link
|
||||
*.lnk
|
||||
*.msi
|
||||
*.nfo
|
||||
*.perl
|
||||
*.php
|
||||
*.pl
|
||||
*.png
|
||||
*.ps1
|
||||
*.psc1
|
||||
*.psd1
|
||||
*.psm1
|
||||
*.py
|
||||
*.pyd
|
||||
*.rb
|
||||
*.readme
|
||||
*.reg
|
||||
*.run
|
||||
*.scr
|
||||
*.sh
|
||||
*.sql
|
||||
*.text
|
||||
*.thumb
|
||||
*.torrent
|
||||
*.txt
|
||||
*.url
|
||||
*.vbs
|
||||
*.wsf
|
||||
*.xml
|
||||
*.zipx
|
||||
@@ -1,9 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
8
code/Common/Configuration/Arr/ArrConfig.cs
Normal file
8
code/Common/Configuration/Arr/ArrConfig.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public abstract record ArrConfig
|
||||
{
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public required List<ArrInstance> Instances { get; init; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Common.Configuration;
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed class SonarrInstance
|
||||
public sealed class ArrInstance
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
6
code/Common/Configuration/Arr/RadarrConfig.cs
Normal file
6
code/Common/Configuration/Arr/RadarrConfig.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record RadarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Radarr";
|
||||
}
|
||||
8
code/Common/Configuration/Arr/SonarrConfig.cs
Normal file
8
code/Common/Configuration/Arr/SonarrConfig.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record SonarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Sonarr";
|
||||
|
||||
public SonarrSearchType SearchType { get; init; }
|
||||
}
|
||||
8
code/Common/Configuration/Arr/SonarrSearchType.cs
Normal file
8
code/Common/Configuration/Arr/SonarrSearchType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public enum SonarrSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
Series
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
|
||||
public sealed record ContentBlockerConfig : IJobConfig
|
||||
{
|
||||
public const string SectionName = "ContentBlocker";
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public PatternConfig? Blacklist { get; init; }
|
||||
|
||||
public PatternConfig? Whitelist { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Blacklist is null && Whitelist is null)
|
||||
{
|
||||
throw new Exception("content blocker is enabled, but both blacklist and whitelist are missing");
|
||||
}
|
||||
|
||||
if (Blacklist?.Enabled is true && Whitelist?.Enabled is true)
|
||||
{
|
||||
throw new Exception("only one exclusion (blacklist/whitelist) list is allowed");
|
||||
}
|
||||
|
||||
if (Blacklist?.Enabled is true && string.IsNullOrEmpty(Blacklist.Path))
|
||||
{
|
||||
throw new Exception("blacklist path is required");
|
||||
}
|
||||
|
||||
if (Whitelist?.Enabled is true && string.IsNullOrEmpty(Whitelist.Path))
|
||||
{
|
||||
throw new Exception("blacklist path is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
|
||||
public sealed record PatternConfig
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
18
code/Common/Configuration/DownloadClient/DelugeConfig.cs
Normal file
18
code/Common/Configuration/DownloadClient/DelugeConfig.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed record DelugeConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Deluge";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
20
code/Common/Configuration/DownloadClient/QBitConfig.cs
Normal file
20
code/Common/Configuration/DownloadClient/QBitConfig.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed class QBitConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "qBittorrent";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public record TransmissionConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Transmission";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
6
code/Common/Configuration/EnvironmentVariables.cs
Normal file
6
code/Common/Configuration/EnvironmentVariables.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public static class EnvironmentVariables
|
||||
{
|
||||
public const string DownloadClient = "DOWNLOAD_CLIENT";
|
||||
}
|
||||
6
code/Common/Configuration/IConfig.cs
Normal file
6
code/Common/Configuration/IConfig.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public interface IConfig
|
||||
{
|
||||
void Validate();
|
||||
}
|
||||
6
code/Common/Configuration/IJobConfig.cs
Normal file
6
code/Common/Configuration/IJobConfig.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public interface IJobConfig : IConfig
|
||||
{
|
||||
bool Enabled { get; init; }
|
||||
}
|
||||
12
code/Common/Configuration/Logging/FileLogConfig.cs
Normal file
12
code/Common/Configuration/Logging/FileLogConfig.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Common.Configuration.Logging;
|
||||
|
||||
public class FileLogConfig : IConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
18
code/Common/Configuration/Logging/LoggingConfig.cs
Normal file
18
code/Common/Configuration/Logging/LoggingConfig.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Common.Configuration.Logging;
|
||||
|
||||
public class LoggingConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Logging";
|
||||
|
||||
public LogEventLevel LogLevel { get; set; }
|
||||
|
||||
public bool Enhanced { get; set; }
|
||||
|
||||
public FileLogConfig? File { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class QBitConfig
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
public required string Username { get; set; }
|
||||
|
||||
public required string Password { get; set; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class QuartzConfig
|
||||
{
|
||||
public required string BlockedTorrentTrigger { get; init; }
|
||||
}
|
||||
22
code/Common/Configuration/QueueCleaner/QueueCleanerConfig.cs
Normal file
22
code/Common/Configuration/QueueCleaner/QueueCleanerConfig.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.QueueCleaner;
|
||||
|
||||
public sealed record QueueCleanerConfig : IJobConfig
|
||||
{
|
||||
public const string SectionName = "QueueCleaner";
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public required bool RunSequentially { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
|
||||
public ushort ImportFailedMaxStrikes { get; init; }
|
||||
|
||||
[ConfigurationKeyName("STALLED_MAX_STRIKES")]
|
||||
public ushort StalledMaxStrikes { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class SonarrConfig
|
||||
{
|
||||
public required List<SonarrInstance> Instances { get; set; }
|
||||
}
|
||||
10
code/Common/Configuration/TriggersConfig.cs
Normal file
10
code/Common/Configuration/TriggersConfig.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class TriggersConfig
|
||||
{
|
||||
public const string SectionName = "Triggers";
|
||||
|
||||
public required string QueueCleaner { get; init; }
|
||||
|
||||
public required string ContentBlocker { get; init; }
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
7
code/Domain/Enums/BlocklistType.cs
Normal file
7
code/Domain/Enums/BlocklistType.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum BlocklistType
|
||||
{
|
||||
Blacklist,
|
||||
Whitelist
|
||||
}
|
||||
8
code/Domain/Enums/DownloadClient.cs
Normal file
8
code/Domain/Enums/DownloadClient.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum DownloadClient
|
||||
{
|
||||
QBittorrent,
|
||||
Deluge,
|
||||
Transmission
|
||||
}
|
||||
9
code/Domain/Enums/InstanceType.cs
Normal file
9
code/Domain/Enums/InstanceType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum InstanceType
|
||||
{
|
||||
Sonarr,
|
||||
Radarr,
|
||||
Lidarr,
|
||||
Readarr
|
||||
}
|
||||
7
code/Domain/Enums/StrikeType.cs
Normal file
7
code/Domain/Enums/StrikeType.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum StrikeType
|
||||
{
|
||||
Stalled,
|
||||
ImportFailed
|
||||
}
|
||||
7
code/Domain/Models/Arr/Queue/QueueListResponse.cs
Normal file
7
code/Domain/Models/Arr/Queue/QueueListResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Domain.Models.Arr.Queue;
|
||||
|
||||
public record QueueListResponse
|
||||
{
|
||||
public required int TotalRecords { get; init; }
|
||||
public required IReadOnlyList<QueueRecord> Records { get; init; }
|
||||
}
|
||||
16
code/Domain/Models/Arr/Queue/QueueRecord.cs
Normal file
16
code/Domain/Models/Arr/Queue/QueueRecord.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Domain.Models.Arr.Queue;
|
||||
|
||||
public record QueueRecord
|
||||
{
|
||||
public int SeriesId { get; init; }
|
||||
public int EpisodeId { get; init; }
|
||||
public int SeasonNumber { get; init; }
|
||||
public int MovieId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string Status { get; init; }
|
||||
public string TrackedDownloadStatus { get; init; }
|
||||
public string TrackedDownloadState { get; init; }
|
||||
public required string DownloadId { get; init; }
|
||||
public required string Protocol { get; init; }
|
||||
public required int Id { get; init; }
|
||||
}
|
||||
21
code/Domain/Models/Arr/SearchItem.cs
Normal file
21
code/Domain/Models/Arr/SearchItem.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Domain.Models.Arr;
|
||||
|
||||
public class SearchItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not SearchItem other)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Id == other.Id;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return Id.GetHashCode();
|
||||
}
|
||||
}
|
||||
25
code/Domain/Models/Arr/SonarrSearchItem.cs
Normal file
25
code/Domain/Models/Arr/SonarrSearchItem.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Common.Configuration.Arr;
|
||||
|
||||
namespace Domain.Models.Arr;
|
||||
|
||||
public sealed class SonarrSearchItem : SearchItem
|
||||
{
|
||||
public long SeriesId { get; set; }
|
||||
|
||||
public SonarrSearchType SearchType { get; set; }
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not SonarrSearchItem other)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Id == other.Id && SeriesId == other.SeriesId;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Id, SeriesId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Models.Deluge.Exceptions;
|
||||
|
||||
public class DelugeClientException : Exception
|
||||
{
|
||||
public DelugeClientException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Models.Deluge.Exceptions;
|
||||
|
||||
public sealed class DelugeLoginException : DelugeClientException
|
||||
{
|
||||
public DelugeLoginException() : base("login failed")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Models.Deluge.Exceptions;
|
||||
|
||||
public sealed class DelugeLogoutException : DelugeClientException
|
||||
{
|
||||
public DelugeLogoutException() : base("logout failed")
|
||||
{
|
||||
}
|
||||
}
|
||||
32
code/Domain/Models/Deluge/Request/DelugeRequest.cs
Normal file
32
code/Domain/Models/Deluge/Request/DelugeRequest.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Domain.Models.Deluge.Request;
|
||||
|
||||
public class DelugeRequest
|
||||
{
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
public int RequestId { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "method")]
|
||||
public String Method { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "params")]
|
||||
public List<Object> Params { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public NullValueHandling NullValueHandling { get; set; }
|
||||
|
||||
public DelugeRequest(int requestId, String method, params object[] parameters)
|
||||
{
|
||||
RequestId = requestId;
|
||||
Method = method;
|
||||
Params = new List<Object>();
|
||||
|
||||
if (parameters != null)
|
||||
{
|
||||
Params.AddRange(parameters);
|
||||
}
|
||||
|
||||
NullValueHandling = NullValueHandling.Include;
|
||||
}
|
||||
}
|
||||
12
code/Domain/Models/Deluge/Response/DelugeContents.cs
Normal file
12
code/Domain/Models/Deluge/Response/DelugeContents.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record DelugeContents
|
||||
{
|
||||
[JsonPropertyName("contents")]
|
||||
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } // Always "dir" for the root
|
||||
}
|
||||
12
code/Domain/Models/Deluge/Response/DelugeError.cs
Normal file
12
code/Domain/Models/Deluge/Response/DelugeError.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record DelugeError
|
||||
{
|
||||
[JsonProperty(PropertyName = "message")]
|
||||
public String Message { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "code")]
|
||||
public int Code { get; set; }
|
||||
}
|
||||
33
code/Domain/Models/Deluge/Response/DelugeFileOrDirectory.cs
Normal file
33
code/Domain/Models/Deluge/Response/DelugeFileOrDirectory.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public class DelugeFileOrDirectory
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } // "file" or "dir"
|
||||
|
||||
[JsonPropertyName("contents")]
|
||||
public Dictionary<string, DelugeFileOrDirectory>? Contents { get; set; } // Recursive property for directories
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; set; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public int? Size { get; set; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; set; }
|
||||
|
||||
[JsonPropertyName("progress")]
|
||||
public double? Progress { get; set; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public required int Priority { get; set; }
|
||||
|
||||
[JsonPropertyName("progresses")]
|
||||
public List<double> Progresses { get; set; }
|
||||
}
|
||||
15
code/Domain/Models/Deluge/Response/DelugeResponse.cs
Normal file
15
code/Domain/Models/Deluge/Response/DelugeResponse.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record DelugeResponse<T>
|
||||
{
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
public int ResponseId { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "result")]
|
||||
public T? Result { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "error")]
|
||||
public DelugeError? Error { get; set; }
|
||||
}
|
||||
30
code/Domain/Models/Deluge/Response/DelugeTorrent.cs
Normal file
30
code/Domain/Models/Deluge/Response/DelugeTorrent.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public record DelugeTorrent
|
||||
{
|
||||
[JsonProperty(PropertyName = "comment")]
|
||||
public string Comment { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "is_seed")]
|
||||
public bool IsSeed { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "hash")]
|
||||
public string Hash { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "paused")]
|
||||
public bool Paused { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "ratio")]
|
||||
public double Ratio { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "label")]
|
||||
public string Label { get; set; }
|
||||
}
|
||||
110
code/Domain/Models/Deluge/Response/DelugeTorrentExtended.cs
Normal file
110
code/Domain/Models/Deluge/Response/DelugeTorrentExtended.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record DelugeTorrentExtended : DelugeTorrent
|
||||
{
|
||||
[JsonProperty(PropertyName = "total_done")]
|
||||
public long TotalDone { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_payload_download")]
|
||||
public long TotalPayloadDownload { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_uploaded")]
|
||||
public long TotalUploaded { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "next_announce")]
|
||||
public int NextAnnounce { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tracker_status")]
|
||||
public string TrackerStatus { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "num_pieces")]
|
||||
public int NumPieces { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "piece_length")]
|
||||
public long PieceLength { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "is_auto_managed")]
|
||||
public bool IsAutoManaged { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "active_time")]
|
||||
public long ActiveTime { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "seeding_time")]
|
||||
public long SeedingTime { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "time_since_transfer")]
|
||||
public long TimeSinceTransfer { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "seed_rank")]
|
||||
public int SeedRank { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "last_seen_complete")]
|
||||
public long LastSeenComplete { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "completed_time")]
|
||||
public long CompletedTime { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "owner")] public string Owner { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "public")]
|
||||
public bool Public { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "shared")]
|
||||
public bool Shared { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "queue")] public int Queue { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_wanted")]
|
||||
public long TotalWanted { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "state")] public string State { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "progress")]
|
||||
public float Progress { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "num_seeds")]
|
||||
public int NumSeeds { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_seeds")]
|
||||
public int TotalSeeds { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "num_peers")]
|
||||
public int NumPeers { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_peers")]
|
||||
public int TotalPeers { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "download_payload_rate")]
|
||||
public long DownloadPayloadRate { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "upload_payload_rate")]
|
||||
public long UploadPayloadRate { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "eta")] public long Eta { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "distributed_copies")]
|
||||
public float DistributedCopies { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "time_added")]
|
||||
public int TimeAdded { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tracker_host")]
|
||||
public string TrackerHost { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "download_location")]
|
||||
public string DownloadLocation { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "total_remaining")]
|
||||
public long TotalRemaining { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "max_download_speed")]
|
||||
public long MaxDownloadSpeed { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "max_upload_speed")]
|
||||
public long MaxUploadSpeed { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "seeds_peers_ratio")]
|
||||
public float SeedsPeersRatio { get; set; }
|
||||
}
|
||||
12
code/Domain/Models/Deluge/Response/TorrentStatus.cs
Normal file
12
code/Domain/Models/Deluge/Response/TorrentStatus.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record TorrentStatus
|
||||
{
|
||||
public string? Hash { get; set; }
|
||||
|
||||
public string? State { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public ulong Eta { get; set; }
|
||||
}
|
||||
8
code/Domain/Models/Radarr/Movie.cs
Normal file
8
code/Domain/Models/Radarr/Movie.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Models.Radarr;
|
||||
|
||||
public sealed record Movie
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
|
||||
public required string Title { get; init; }
|
||||
}
|
||||
8
code/Domain/Models/Radarr/RadarrCommand.cs
Normal file
8
code/Domain/Models/Radarr/RadarrCommand.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Models.Radarr;
|
||||
|
||||
public sealed record RadarrCommand
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required List<long> MovieIds { get; init; }
|
||||
}
|
||||
12
code/Domain/Models/Sonarr/Episode.cs
Normal file
12
code/Domain/Models/Sonarr/Episode.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Domain.Models.Sonarr;
|
||||
|
||||
public sealed record Episode
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public int EpisodeNumber { get; set; }
|
||||
|
||||
public int SeasonNumber { get; set; }
|
||||
|
||||
public long SeriesId { get; set; }
|
||||
}
|
||||
8
code/Domain/Models/Sonarr/Series.cs
Normal file
8
code/Domain/Models/Sonarr/Series.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Models.Sonarr;
|
||||
|
||||
public sealed record Series
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
|
||||
public required string Title { get; init; }
|
||||
}
|
||||
16
code/Domain/Models/Sonarr/SonarrCommand.cs
Normal file
16
code/Domain/Models/Sonarr/SonarrCommand.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Common.Configuration.Arr;
|
||||
|
||||
namespace Domain.Models.Sonarr;
|
||||
|
||||
public sealed record SonarrCommand
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public long? SeriesId { get; set; }
|
||||
|
||||
public long? SeasonNumber { get; set; }
|
||||
|
||||
public List<long>? EpisodeIds { get; set; }
|
||||
|
||||
public SonarrSearchType SearchType { get; set; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record CustomFormat(
|
||||
int Id,
|
||||
string Name
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Language(
|
||||
int Id,
|
||||
string Name
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record QueueListResponse(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string SortKey,
|
||||
string SortDirection,
|
||||
int TotalRecords,
|
||||
IReadOnlyList<Record> Records
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Record(
|
||||
int SeriesId,
|
||||
int EpisodeId,
|
||||
int SeasonNumber,
|
||||
IReadOnlyList<Language> Languages,
|
||||
IReadOnlyList<CustomFormat> CustomFormats,
|
||||
int CustomFormatScore,
|
||||
int Size,
|
||||
string Title,
|
||||
int Sizeleft,
|
||||
string Timeleft,
|
||||
DateTime EstimatedCompletionTime,
|
||||
DateTime Added,
|
||||
string Status,
|
||||
string TrackedDownloadStatus,
|
||||
string TrackedDownloadState,
|
||||
IReadOnlyList<StatusMessage> StatusMessages,
|
||||
string DownloadId,
|
||||
string Protocol,
|
||||
string DownloadClient,
|
||||
bool DownloadClientHasPostImportCategory,
|
||||
string Indexer,
|
||||
string OutputPath,
|
||||
bool EpisodeHasFile,
|
||||
int Id
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Revision(
|
||||
int Version,
|
||||
int Real,
|
||||
bool IsRepack
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record StatusMessage(
|
||||
string Title,
|
||||
IReadOnlyList<string> Messages
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.BlockedTorrent;
|
||||
|
||||
namespace Executable;
|
||||
using Quartz;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClient()
|
||||
.AddConfiguration(configuration)
|
||||
.AddServices()
|
||||
.AddQuartzServices(configuration);
|
||||
|
||||
private static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.Configure<QuartzConfig>(configuration.GetSection(nameof(QuartzConfig)))
|
||||
.Configure<QBitConfig>(configuration.GetSection(nameof(QBitConfig)))
|
||||
.Configure<SonarrConfig>(configuration.GetSection(nameof(SonarrConfig)));
|
||||
|
||||
private static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddTransient<BlockedTorrentJob>()
|
||||
.AddTransient<BlockedTorrentHandler>();
|
||||
|
||||
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get<QuartzConfig>();
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException("Quartz configuration is null");
|
||||
}
|
||||
|
||||
q.AddBlockedTorrentJob(config.BlockedTorrentTrigger);
|
||||
})
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
|
||||
private static void AddBlockedTorrentJob(this IServiceCollectionQuartzConfigurator q, string trigger)
|
||||
{
|
||||
q.AddJob<BlockedTorrentJob>(opts =>
|
||||
{
|
||||
opts.WithIdentity(nameof(BlockedTorrentJob));
|
||||
});
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(nameof(BlockedTorrentJob))
|
||||
.WithIdentity($"{nameof(BlockedTorrentJob)}-trigger")
|
||||
.WithCronSchedule(trigger);
|
||||
});
|
||||
}
|
||||
}
|
||||
23
code/Executable/DependencyInjection/ConfigurationDI.cs
Normal file
23
code/Executable/DependencyInjection/ConfigurationDI.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class ConfigurationDI
|
||||
{
|
||||
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
|
||||
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
|
||||
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
||||
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
|
||||
.Configure<TransmissionConfig>(configuration.GetSection(TransmissionConfig.SectionName))
|
||||
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
|
||||
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName))
|
||||
.Configure<LoggingConfig>(configuration.GetSection(LoggingConfig.SectionName));
|
||||
}
|
||||
67
code/Executable/DependencyInjection/LoggingDI.cs
Normal file
67
code/Executable/DependencyInjection/LoggingDI.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Common.Configuration.Logging;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration)
|
||||
{
|
||||
LoggingConfig? config = configuration.GetSection(LoggingConfig.SectionName).Get<LoggingConfig>();
|
||||
|
||||
if (!string.IsNullOrEmpty(config?.File?.Path) && !Directory.Exists(config.File.Path))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(config.File.Path);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"log file path is not a valid directory | {config.File.Path}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string consoleOutputTemplate = "[{@t:yyyy-MM-dd HH:mm:ss.fff} {@l:u3}]{#if JobName is not null} {Concat('[',JobName,']'),PAD}{#end} {@m}\n{@x}";
|
||||
const string fileOutputTemplate = "{@t:yyyy-MM-dd HH:mm:ss.fff zzz} [{@l:u3}]{#if JobName is not null} {Concat('[',JobName,']'),PAD}{#end} {@m:lj}\n{@x}";
|
||||
LogEventLevel level = LogEventLevel.Information;
|
||||
List<string> jobNames = [nameof(ContentBlocker), nameof(QueueCleaner)];
|
||||
int padding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
if (config is not null)
|
||||
{
|
||||
level = config.LogLevel;
|
||||
|
||||
if (config.File?.Enabled is true)
|
||||
{
|
||||
logConfig.WriteTo.File(
|
||||
path: Path.Combine(config.File.Path, "cleanuperr-.txt"),
|
||||
formatter: new ExpressionTemplate(fileOutputTemplate.Replace("PAD", padding.ToString())),
|
||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Logger = logConfig
|
||||
.MinimumLevel.Is(level)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.WriteTo.Console(new ExpressionTemplate(consoleOutputTemplate.Replace("PAD", padding.ToString())))
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("ApplicationName", "cleanuperr")
|
||||
.CreateLogger();
|
||||
|
||||
return builder
|
||||
.ClearProviders()
|
||||
.AddSerilog();
|
||||
}
|
||||
}
|
||||
51
code/Executable/DependencyInjection/MainDI.cs
Normal file
51
code/Executable/DependencyInjection/MainDI.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients()
|
||||
.AddConfiguration(configuration)
|
||||
.AddMemoryCache()
|
||||
.AddServices()
|
||||
.AddQuartzServices(configuration);
|
||||
|
||||
private static IServiceCollection AddHttpClients(this IServiceCollection services)
|
||||
{
|
||||
// add default HttpClient
|
||||
services.AddHttpClient();
|
||||
|
||||
// add Deluge HttpClient
|
||||
services
|
||||
.AddHttpClient(nameof(DelugeService), x =>
|
||||
{
|
||||
x.Timeout = TimeSpan.FromSeconds(5);
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(_ =>
|
||||
{
|
||||
return new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer(),
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
128
code/Executable/DependencyInjection/QuartzDI.cs
Normal file
128
code/Executable/DependencyInjection/QuartzDI.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Quartz;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class QuartzDI
|
||||
{
|
||||
public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
TriggersConfig? config = configuration
|
||||
.GetRequiredSection(TriggersConfig.SectionName)
|
||||
.Get<TriggersConfig>();
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException("triggers configuration is null");
|
||||
}
|
||||
|
||||
q.AddJobs(configuration, config);
|
||||
})
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
|
||||
private static void AddJobs(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IConfiguration configuration,
|
||||
TriggersConfig triggersConfig
|
||||
)
|
||||
{
|
||||
ContentBlockerConfig? contentBlockerConfig = configuration
|
||||
.GetRequiredSection(ContentBlockerConfig.SectionName)
|
||||
.Get<ContentBlockerConfig>();
|
||||
|
||||
q.AddJob<ContentBlocker>(contentBlockerConfig, triggersConfig.ContentBlocker);
|
||||
|
||||
QueueCleanerConfig? queueCleanerConfig = configuration
|
||||
.GetRequiredSection(QueueCleanerConfig.SectionName)
|
||||
.Get<QueueCleanerConfig>();
|
||||
|
||||
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
|
||||
q.AddJobListener(new JobChainingListener(nameof(QueueCleaner)));
|
||||
}
|
||||
else
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, triggersConfig.QueueCleaner);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJob<T>(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IJobConfig? config,
|
||||
string trigger
|
||||
) where T: GenericHandler
|
||||
{
|
||||
string typeName = typeof(T).Name;
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException($"{typeName} configuration is null");
|
||||
}
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasTrigger = trigger.Length > 0;
|
||||
|
||||
q.AddJob<GenericJob<T>>(opts =>
|
||||
{
|
||||
opts.WithIdentity(typeName);
|
||||
|
||||
if (!hasTrigger)
|
||||
{
|
||||
// jobs with no triggers need to be stored durably
|
||||
opts.StoreDurably();
|
||||
}
|
||||
});
|
||||
|
||||
// skip empty triggers
|
||||
if (!hasTrigger)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ExampleTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(trigger)
|
||||
.Build();
|
||||
|
||||
var nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
|
||||
if (nextFireTimes[1] - nextFireTimes[0] > TimeSpan.FromHours(1))
|
||||
{
|
||||
throw new Exception($"{trigger} should have a fire time of maximum 1 hour");
|
||||
}
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-trigger")
|
||||
.WithCronSchedule(trigger, x =>x.WithMisfireHandlingInstructionDoNothing())
|
||||
.StartNow();
|
||||
});
|
||||
|
||||
// Startup trigger
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-startup-trigger")
|
||||
.StartNow();
|
||||
});
|
||||
}
|
||||
}
|
||||
28
code/Executable/DependencyInjection/ServicesDI.cs
Normal file
28
code/Executable/DependencyInjection/ServicesDI.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<FilenameEvaluator>()
|
||||
.AddTransient<QBitService>()
|
||||
.AddTransient<DelugeService>()
|
||||
.AddTransient<TransmissionService>()
|
||||
.AddTransient<ArrQueueIterator>()
|
||||
.AddTransient<DownloadServiceFactory>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<Striker>();
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>cleanuperr</AssemblyName>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
using Infrastructure.Verticals.BlockedTorrent;
|
||||
using Quartz;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class BlockedTorrentJob : IJob
|
||||
{
|
||||
private ILogger<BlockedTorrentJob> _logger;
|
||||
private BlockedTorrentHandler _handler;
|
||||
|
||||
public BlockedTorrentJob(ILogger<BlockedTorrentJob> logger, BlockedTorrentHandler handler)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _handler.HandleAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(BlockedTorrentJob)} failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
33
code/Executable/Jobs/GenericJob.cs
Normal file
33
code/Executable/Jobs/GenericJob.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Quartz;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class GenericJob<T> : IJob
|
||||
where T : GenericHandler
|
||||
{
|
||||
private readonly ILogger<GenericJob<T>> _logger;
|
||||
private readonly T _handler;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
|
||||
|
||||
try
|
||||
{
|
||||
await _handler.ExecuteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{name} failed", typeof(T).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
using Executable;
|
||||
using Executable.DependencyInjection;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Logging.AddLogging(builder.Configuration);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
|
||||
@@ -1,11 +1,65 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
"LogLevel": "Debug",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"QuartzConfig": {
|
||||
"BlockedTorrentTrigger": "0 0/1 * * * ?"
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0/10 * * * * ?",
|
||||
"ContentBlocker": "0/10 * * * * ?"
|
||||
},
|
||||
"ContentBlocker": {
|
||||
"Enabled": true,
|
||||
"Blacklist": {
|
||||
"Enabled": false,
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
|
||||
},
|
||||
"Whitelist": {
|
||||
"Enabled": false,
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist"
|
||||
}
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"STALLED_MAX_STRIKES": 5
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"SearchType": "Episode",
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": "96736c3eb3144936b8f1d62d27be8cee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": true,
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": "705b553732ab4167ab23909305d60600"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,65 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Quartz": "Warning",
|
||||
"System.Net.Http.HttpClient": "Error"
|
||||
"LogLevel": "Information",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"QuartzConfig": {
|
||||
"BlockedTorrentTrigger": "0 0/5 * * * ?"
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0 0/5 * * * ?",
|
||||
"ContentBlocker": "0 0/5 * * * ?"
|
||||
},
|
||||
"QBitConfig": {
|
||||
"ContentBlocker": {
|
||||
"Enabled": false,
|
||||
"Blacklist": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
},
|
||||
"Whitelist": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"STALLED_MAX_STRIKES": 5
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"SonarrConfig": {
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"SearchType": "Episode",
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": false,
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
@@ -12,9 +12,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
137
code/Infrastructure/Verticals/Arr/ArrClient.cs
Normal file
137
code/Infrastructure/Verticals/Arr/ArrClient.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Arr;
|
||||
|
||||
public abstract class ArrClient
|
||||
{
|
||||
protected readonly ILogger<ArrClient> _logger;
|
||||
protected readonly HttpClient _httpClient;
|
||||
protected readonly LoggingConfig _loggingConfig;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly Striker _striker;
|
||||
|
||||
protected ArrClient(
|
||||
ILogger<ArrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<LoggingConfig> loggingConfig,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
Striker striker
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_striker = striker;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_loggingConfig = loggingConfig.Value;
|
||||
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page));
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue list failed | {uri}", uri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public virtual bool ShouldRemoveFromQueue(QueueRecord record)
|
||||
{
|
||||
bool hasWarn() => record.TrackedDownloadStatus
|
||||
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportBlocked() => record.TrackedDownloadState
|
||||
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportPending() => record.TrackedDownloadState
|
||||
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
if (hasWarn() && (isImportBlocked() || isImportPending()))
|
||||
{
|
||||
return _striker.StrikeAndCheckLimit(
|
||||
record.DownloadId,
|
||||
record.Title,
|
||||
_queueCleanerConfig.ImportFailedMaxStrikes,
|
||||
StrikeType.ImportFailed
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord queueRecord)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, $"/api/v3/queue/{queueRecord.Id}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false");
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Delete, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("queue item deleted | {url} | {title}", arrInstance.Url, queueRecord.Title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue delete failed | {uri} | {title}", uri, queueRecord.Title);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items);
|
||||
|
||||
public virtual bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.DownloadId.Equals(record.Title, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("skip | item is not ready yet | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected abstract string GetQueueUrlPath(int page);
|
||||
|
||||
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
|
||||
{
|
||||
request.Headers.Add("x-api-key", apiKey);
|
||||
}
|
||||
}
|
||||
54
code/Infrastructure/Verticals/Arr/ArrQueueIterator.cs
Normal file
54
code/Infrastructure/Verticals/Arr/ArrQueueIterator.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.Arr;
|
||||
|
||||
public sealed class ArrQueueIterator
|
||||
{
|
||||
private readonly ILogger<ArrQueueIterator> _logger;
|
||||
|
||||
public ArrQueueIterator(ILogger<ArrQueueIterator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Iterate(ArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action)
|
||||
{
|
||||
const ushort maxPage = 100;
|
||||
ushort page = 1;
|
||||
int totalRecords = 0;
|
||||
int processedRecords = 0;
|
||||
|
||||
do
|
||||
{
|
||||
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page);
|
||||
|
||||
if (totalRecords is 0)
|
||||
{
|
||||
totalRecords = queueResponse.TotalRecords;
|
||||
|
||||
_logger.LogInformation(
|
||||
"{items} items found in queue | {url}",
|
||||
queueResponse.TotalRecords, arrInstance.Url);
|
||||
}
|
||||
|
||||
if (queueResponse.Records.Count is 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await action(queueResponse.Records);
|
||||
|
||||
processedRecords += queueResponse.Records.Count;
|
||||
|
||||
if (processedRecords >= totalRecords)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (processedRecords < totalRecords && page < maxPage);
|
||||
}
|
||||
}
|
||||
137
code/Infrastructure/Verticals/Arr/RadarrClient.cs
Normal file
137
code/Infrastructure/Verticals/Arr/RadarrClient.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System.Text;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Domain.Models.Radarr;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Arr;
|
||||
|
||||
public sealed class RadarrClient : ArrClient
|
||||
{
|
||||
public RadarrClient(
|
||||
ILogger<ArrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<LoggingConfig> loggingConfig,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
Striker striker
|
||||
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlPath(int page)
|
||||
{
|
||||
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
|
||||
}
|
||||
|
||||
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<long> ids = items.Select(item => item.Id).ToList();
|
||||
|
||||
Uri uri = new(arrInstance.Url, "/api/v3/command");
|
||||
RadarrCommand command = new()
|
||||
{
|
||||
Name = "MoviesSearch",
|
||||
MovieIds = ids,
|
||||
};
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(command),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.MovieId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | item information missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
|
||||
{
|
||||
string status = success ? "triggered" : "failed";
|
||||
string message = logContext ?? $"movie ids: {string.Join(',', command.MovieIds)}";
|
||||
|
||||
return $"movie search {status} | {instanceUrl} | {message}";
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, RadarrCommand command)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_loggingConfig.Enhanced)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder log = new();
|
||||
|
||||
foreach (long movieId in command.MovieIds)
|
||||
{
|
||||
Movie? movie = await GetMovie(arrInstance, movieId);
|
||||
|
||||
if (movie is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{movie.Title}]");
|
||||
}
|
||||
|
||||
return log.ToString();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to compute log context");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, $"api/v3/movie/{movieId}");
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Movie>(responseBody);
|
||||
}
|
||||
}
|
||||
265
code/Infrastructure/Verticals/Arr/SonarrClient.cs
Normal file
265
code/Infrastructure/Verticals/Arr/SonarrClient.cs
Normal file
@@ -0,0 +1,265 @@
|
||||
using System.Text;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Domain.Models.Sonarr;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Arr;
|
||||
|
||||
public sealed class SonarrClient : ArrClient
|
||||
{
|
||||
public SonarrClient(
|
||||
ILogger<SonarrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<LoggingConfig> loggingConfig,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
Striker striker
|
||||
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlPath(int page)
|
||||
{
|
||||
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true";
|
||||
}
|
||||
|
||||
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = new(arrInstance.Url, "/api/v3/command");
|
||||
|
||||
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | item information missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
|
||||
private static string GetSearchLog(
|
||||
SonarrSearchType searchType,
|
||||
Uri instanceUrl,
|
||||
SonarrCommand command,
|
||||
bool success,
|
||||
string? logContext
|
||||
)
|
||||
{
|
||||
string status = success ? "triggered" : "failed";
|
||||
|
||||
return searchType switch
|
||||
{
|
||||
SonarrSearchType.Episode =>
|
||||
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
|
||||
SonarrSearchType.Season =>
|
||||
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
|
||||
SonarrSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SonarrSearchType searchType)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_loggingConfig.Enhanced)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder log = new();
|
||||
|
||||
if (searchType is SonarrSearchType.Episode)
|
||||
{
|
||||
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
|
||||
|
||||
if (episodes?.Count is null or 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesIds = episodes
|
||||
.Select(x => x.SeriesId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<Series> series = [];
|
||||
|
||||
foreach (long id in seriesIds)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, id);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
series.Add(show);
|
||||
}
|
||||
|
||||
foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
|
||||
{
|
||||
var show = series.First(x => x.Id == group.Key);
|
||||
var episode = episodes
|
||||
.Where(ep => group.Any(x => x == ep.Id))
|
||||
.OrderBy(x => x.SeasonNumber)
|
||||
.ThenBy(x => x.EpisodeNumber)
|
||||
.Select(x => $"S{x.SeasonNumber.ToString().PadLeft(2, '0')}E{x.EpisodeNumber.ToString().PadLeft(2, '0')}")
|
||||
.ToList();
|
||||
|
||||
log.Append($"[{show.Title} {string.Join(',', episode)}]");
|
||||
}
|
||||
}
|
||||
|
||||
if (searchType is SonarrSearchType.Season)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{show.Title} season {command.SeasonNumber}]");
|
||||
}
|
||||
|
||||
if (searchType is SonarrSearchType.Series)
|
||||
{
|
||||
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
|
||||
|
||||
if (show is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
log.Append($"[{show.Title}]");
|
||||
}
|
||||
|
||||
return log.ToString();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to compute log context");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, $"api/v3/episode?{string.Join('&', episodeIds.Select(x => $"episodeIds={x}"))}");
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<List<Episode>>(responseBody);
|
||||
}
|
||||
|
||||
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, $"api/v3/series/{seriesId}");
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Series>(responseBody);
|
||||
}
|
||||
|
||||
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
|
||||
{
|
||||
const string episodeSearch = "EpisodeSearch";
|
||||
const string seasonSearch = "SeasonSearch";
|
||||
const string seriesSearch = "SeriesSearch";
|
||||
|
||||
List<SonarrCommand> commands = new();
|
||||
|
||||
foreach (SonarrSearchItem item in items)
|
||||
{
|
||||
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
|
||||
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
|
||||
: new();
|
||||
|
||||
switch (item.SearchType)
|
||||
{
|
||||
case SonarrSearchType.Episode when command.EpisodeIds is null:
|
||||
command.EpisodeIds = [item.Id];
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Episode when command.EpisodeIds is not null:
|
||||
command.EpisodeIds.Add(item.Id);
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Season:
|
||||
command.Name = seasonSearch;
|
||||
command.SeasonNumber = item.Id;
|
||||
command.SeriesId = ((SonarrSearchItem)item).SeriesId;
|
||||
break;
|
||||
|
||||
case SonarrSearchType.Series:
|
||||
command.Name = seriesSearch;
|
||||
command.SeriesId = item.Id;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
|
||||
}
|
||||
|
||||
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
|
||||
{
|
||||
// only one command will be generated for episodes search
|
||||
continue;
|
||||
}
|
||||
|
||||
command.SearchType = item.SearchType;
|
||||
commands.Add(command);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
using System.Text;
|
||||
using Common.Configuration;
|
||||
using Domain.Sonarr.Queue;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.BlockedTorrent;
|
||||
|
||||
public sealed class BlockedTorrentHandler
|
||||
{
|
||||
private readonly ILogger<BlockedTorrentHandler> _logger;
|
||||
private readonly QBitConfig _qBitConfig;
|
||||
private readonly SonarrConfig _sonarrConfig;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string QueueListPathTemplate = "/api/v3/queue?page={0}&pageSize=200&sortKey=timeleft";
|
||||
private const string QueueDeletePathTemplate = "/api/v3/queue/{0}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false";
|
||||
private const string SonarrCommandUriPath = "/api/v3/command";
|
||||
private const string SearchCommandPayloadTemplate = "{\"name\":\"SeriesSearch\",\"seriesId\":{0}}";
|
||||
|
||||
public BlockedTorrentHandler(
|
||||
ILogger<BlockedTorrentHandler> logger,
|
||||
IOptions<QBitConfig> qBitConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_qBitConfig = qBitConfig.Value;
|
||||
_sonarrConfig = sonarrConfig.Value;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
}
|
||||
|
||||
public async Task HandleAsync()
|
||||
{
|
||||
QBittorrentClient qBitClient = new(_qBitConfig.Url);
|
||||
|
||||
await qBitClient.LoginAsync(_qBitConfig.Username, _qBitConfig.Password);
|
||||
|
||||
foreach (SonarrInstance sonarrInstance in _sonarrConfig.Instances)
|
||||
{
|
||||
ushort page = 1;
|
||||
int totalRecords = 0;
|
||||
int processedRecords = 0;
|
||||
HashSet<int> seriesToBeRefreshed = [];
|
||||
|
||||
do
|
||||
{
|
||||
QueueListResponse queueResponse = await ListQueuedTorrentsAsync(sonarrInstance, page);
|
||||
|
||||
if (totalRecords is 0)
|
||||
{
|
||||
totalRecords = queueResponse.TotalRecords;
|
||||
|
||||
_logger.LogInformation(
|
||||
"{items} items found in queue | {url}",
|
||||
queueResponse.TotalRecords, sonarrInstance.Url);
|
||||
}
|
||||
|
||||
foreach (Record record in queueResponse.Records)
|
||||
{
|
||||
var torrent = (await qBitClient.GetTorrentListAsync(new TorrentListQuery { Hashes = [record.DownloadId] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (torrent is not { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
_logger.LogInformation("skip | {torrent}", record.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
seriesToBeRefreshed.Add(record.SeriesId);
|
||||
|
||||
await DeleteTorrentFromQueueAsync(sonarrInstance, record);
|
||||
}
|
||||
|
||||
if (queueResponse.Records.Count is 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
processedRecords += queueResponse.Records.Count;
|
||||
|
||||
if (processedRecords >= totalRecords)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (processedRecords < totalRecords);
|
||||
|
||||
foreach (int id in seriesToBeRefreshed)
|
||||
{
|
||||
await RefreshSeriesAsync(sonarrInstance, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<QueueListResponse> ListQueuedTorrentsAsync(SonarrInstance sonarrInstance, int page)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueListPathTemplate, page));
|
||||
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Get, sonarrUri);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue list failed | {uri}", sonarrUri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized response | {responseBody}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
private async Task DeleteTorrentFromQueueAsync(SonarrInstance sonarrInstance, Record record)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueDeletePathTemplate, record.Id));
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Delete, sonarrUri);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("queue item deleted | {record}", record.Title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue delete failed | {uri}", sonarrUri);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshSeriesAsync(SonarrInstance sonarrInstance, int seriesId)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, SonarrCommandUriPath);
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Post, sonarrUri);
|
||||
sonarrRequest.Content = new StringContent(
|
||||
SearchCommandPayloadTemplate.Replace("{0}", seriesId.ToString()),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("series search triggered | series id: {id}", seriesId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("series search failed | series id: {id}", seriesId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly ContentBlockerConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public BlocklistType BlocklistType { get; }
|
||||
|
||||
public ConcurrentBag<string> Patterns { get; } = [];
|
||||
|
||||
public ConcurrentBag<Regex> Regexes { get; } = [];
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
IOptions<ContentBlockerConfig> config,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config.Value;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
|
||||
_config.Validate();
|
||||
|
||||
if (_config.Blacklist?.Enabled is true)
|
||||
{
|
||||
BlocklistType = BlocklistType.Blacklist;
|
||||
}
|
||||
|
||||
if (_config.Whitelist?.Enabled is true)
|
||||
{
|
||||
BlocklistType = BlocklistType.Whitelist;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistAsync()
|
||||
{
|
||||
if (Patterns.Count > 0 || Regexes.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("blocklist already loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await LoadPatternsAndRegexesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("failed to load {type}", BlocklistType.ToString());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync()
|
||||
{
|
||||
string[] patterns;
|
||||
|
||||
if (BlocklistType is BlocklistType.Blacklist)
|
||||
{
|
||||
patterns = await ReadContentAsync(_config.Blacklist.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
patterns = await ReadContentAsync(_config.Whitelist.Path);
|
||||
}
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
const string regexId = "regex:";
|
||||
|
||||
Parallel.ForEach(patterns, options, pattern =>
|
||||
{
|
||||
if (!pattern.StartsWith(regexId))
|
||||
{
|
||||
Patterns.Add(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
pattern = pattern[regexId.Length..];
|
||||
|
||||
try
|
||||
{
|
||||
Regex regex = new(pattern, RegexOptions.Compiled);
|
||||
Regexes.Add(regex);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
_logger.LogWarning("invalid regex | {pattern}", pattern);
|
||||
}
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_logger.LogDebug("loaded {count} patterns", Patterns.Count);
|
||||
_logger.LogDebug("loaded {count} regexes", Regexes.Count);
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms", elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
// http(s) url
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"blocklist not found | {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class ContentBlocker : GenericHandler
|
||||
{
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public ContentBlocker(
|
||||
ILogger<ContentBlocker> logger,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(logger, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory)
|
||||
{
|
||||
_blocklistProvider = blocklistProvider;
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
await _blocklistProvider.LoadBlocklistAsync();
|
||||
await base.ExecuteAsync();
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
foreach (QueueRecord record in items)
|
||||
{
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
await _downloadService.BlockUnwantedFilesAsync(record.DownloadId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class FilenameEvaluator
|
||||
{
|
||||
private readonly ILogger<FilenameEvaluator> _logger;
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public FilenameEvaluator(ILogger<FilenameEvaluator> logger, BlocklistProvider blocklistProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_blocklistProvider = blocklistProvider;
|
||||
}
|
||||
|
||||
// TODO create unit tests
|
||||
public bool IsValid(string filename)
|
||||
{
|
||||
return IsValidAgainstPatterns(filename) && IsValidAgainstRegexes(filename);
|
||||
}
|
||||
|
||||
private bool IsValidAgainstPatterns(string filename)
|
||||
{
|
||||
if (_blocklistProvider.Patterns.Count is 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _blocklistProvider.BlocklistType switch
|
||||
{
|
||||
BlocklistType.Blacklist => !_blocklistProvider.Patterns.Any(pattern => MatchesPattern(filename, pattern)),
|
||||
BlocklistType.Whitelist => _blocklistProvider.Patterns.Any(pattern => MatchesPattern(filename, pattern)),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsValidAgainstRegexes(string filename)
|
||||
{
|
||||
if (_blocklistProvider.Regexes.Count is 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _blocklistProvider.BlocklistType switch
|
||||
{
|
||||
BlocklistType.Blacklist => !_blocklistProvider.Regexes.Any(regex => regex.IsMatch(filename)),
|
||||
BlocklistType.Whitelist => _blocklistProvider.Regexes.Any(regex => regex.IsMatch(filename)),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string filename, string pattern)
|
||||
{
|
||||
bool hasStartWildcard = pattern.StartsWith('*');
|
||||
bool hasEndWildcard = pattern.EndsWith('*');
|
||||
|
||||
if (hasStartWildcard && hasEndWildcard)
|
||||
{
|
||||
return filename.Contains(
|
||||
pattern.Substring(1, pattern.Length - 2),
|
||||
StringComparison.InvariantCultureIgnoreCase
|
||||
);
|
||||
}
|
||||
|
||||
if (hasStartWildcard)
|
||||
{
|
||||
return filename.EndsWith(pattern.Substring(1), StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
if (hasEndWildcard)
|
||||
{
|
||||
return filename.StartsWith(
|
||||
pattern.Substring(0, pattern.Length - 1),
|
||||
StringComparison.InvariantCultureIgnoreCase
|
||||
);
|
||||
}
|
||||
|
||||
return filename == pattern;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Serialization;
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Domain.Models.Deluge.Exceptions;
|
||||
using Domain.Models.Deluge.Request;
|
||||
using Domain.Models.Deluge.Response;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public sealed class DelugeClient
|
||||
{
|
||||
private readonly DelugeConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_config = config.Value;
|
||||
_httpClient = httpClientFactory.CreateClient(nameof(DelugeService));
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync()
|
||||
{
|
||||
return await SendRequest<bool>("auth.login", _config.Password);
|
||||
}
|
||||
|
||||
public async Task<bool> Logout()
|
||||
{
|
||||
return await SendRequest<bool>("auth.delete_session");
|
||||
}
|
||||
|
||||
public async Task<List<DelugeTorrent>> ListTorrents(Dictionary<string, string>? filters = null)
|
||||
{
|
||||
filters ??= new Dictionary<string, string>();
|
||||
var keys = typeof(DelugeTorrent).GetAllJsonPropertyFromType();
|
||||
Dictionary<string, DelugeTorrent> result =
|
||||
await SendRequest<Dictionary<string, DelugeTorrent>>("core.get_torrents_status", filters, keys);
|
||||
return result.Values.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<DelugeTorrentExtended>> ListTorrentsExtended(Dictionary<string, string>? filters = null)
|
||||
{
|
||||
filters ??= new Dictionary<string, string>();
|
||||
var keys = typeof(DelugeTorrentExtended).GetAllJsonPropertyFromType();
|
||||
Dictionary<string, DelugeTorrentExtended> result =
|
||||
await SendRequest<Dictionary<string, DelugeTorrentExtended>>("core.get_torrents_status", filters, keys);
|
||||
return result.Values.ToList();
|
||||
}
|
||||
|
||||
public async Task<DelugeTorrent?> GetTorrent(string hash)
|
||||
{
|
||||
List<DelugeTorrent> torrents = await ListTorrents(new Dictionary<string, string>() { { "hash", hash } });
|
||||
return torrents.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<DelugeTorrentExtended?> GetTorrentExtended(string hash)
|
||||
{
|
||||
List<DelugeTorrentExtended> torrents =
|
||||
await ListTorrentsExtended(new Dictionary<string, string> { { "hash", hash } });
|
||||
return torrents.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<DelugeContents?> GetTorrentFiles(string hash)
|
||||
{
|
||||
return await SendRequest<DelugeContents?>("web.get_torrent_files", hash);
|
||||
}
|
||||
|
||||
public async Task ChangeFilesPriority(string hash, List<int> priorities)
|
||||
{
|
||||
Dictionary<string, List<int>> filePriorities = new()
|
||||
{
|
||||
{ "file_priorities", priorities }
|
||||
};
|
||||
|
||||
await SendRequest<DelugeResponse<object>>("core.set_torrent_options", hash, filePriorities);
|
||||
}
|
||||
|
||||
private async Task<String> PostJson(String json)
|
||||
{
|
||||
StringContent content = new StringContent(json);
|
||||
content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json");
|
||||
|
||||
var responseMessage = await _httpClient.PostAsync(new Uri(_config.Url, "/json"), content);
|
||||
responseMessage.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await responseMessage.Content.ReadAsStringAsync();
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
private DelugeRequest CreateRequest(string method, params object[] parameters)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(method))
|
||||
{
|
||||
throw new ArgumentException(nameof(method));
|
||||
}
|
||||
|
||||
return new DelugeRequest(1, method, parameters);
|
||||
}
|
||||
|
||||
public async Task<T> SendRequest<T>(string method, params object[] parameters)
|
||||
{
|
||||
return await SendRequest<T>(CreateRequest(method, parameters));
|
||||
}
|
||||
|
||||
public async Task<T> SendRequest<T>(DelugeRequest webRequest)
|
||||
{
|
||||
var requestJson = JsonConvert.SerializeObject(webRequest, Formatting.None, new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = webRequest.NullValueHandling
|
||||
});
|
||||
|
||||
var responseJson = await PostJson(requestJson);
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
Error = (_, args) =>
|
||||
{
|
||||
// Suppress the error and continue
|
||||
args.ErrorContext.Handled = true;
|
||||
}
|
||||
};
|
||||
|
||||
DelugeResponse<T>? webResponse = JsonConvert.DeserializeObject<DelugeResponse<T>>(responseJson, settings);
|
||||
|
||||
if (webResponse?.Error != null)
|
||||
{
|
||||
throw new DelugeClientException(webResponse.Error.Message);
|
||||
}
|
||||
|
||||
if (webResponse?.ResponseId != webRequest.RequestId)
|
||||
{
|
||||
throw new DelugeClientException("desync");
|
||||
}
|
||||
|
||||
return webResponse.Result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Models.Deluge.Response;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public sealed class DelugeService : DownloadServiceBase
|
||||
{
|
||||
private readonly DelugeClient _client;
|
||||
|
||||
public DelugeService(
|
||||
ILogger<DelugeService> logger,
|
||||
IOptions<DelugeConfig> config,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
config.Value.Validate();
|
||||
_client = new (config, httpClientFactory);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.LoginAsync();
|
||||
}
|
||||
|
||||
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
DelugeContents? contents = null;
|
||||
|
||||
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||
|
||||
if (status?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
bool shouldRemove = contents?.Contents?.Count > 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (_, file) =>
|
||||
{
|
||||
if (file.Priority > 0)
|
||||
{
|
||||
shouldRemove = false;
|
||||
}
|
||||
});
|
||||
|
||||
return shouldRemove || IsItemStuckAndShouldRemove(status);
|
||||
}
|
||||
|
||||
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||
|
||||
if (status?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
DelugeContents? contents = null;
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
if (contents is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<int, int> priorities = [];
|
||||
bool hasPriorityUpdates = false;
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
int priority = file.Priority;
|
||||
|
||||
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name))
|
||||
{
|
||||
priority = 0;
|
||||
hasPriorityUpdates = true;
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
}
|
||||
|
||||
priorities.Add(file.Index, priority);
|
||||
});
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
List<int> sortedPriorities = priorities
|
||||
.OrderBy(x => x.Key)
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentStatus status)
|
||||
{
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(status.Hash!, status.Name!);
|
||||
}
|
||||
|
||||
private async Task<TorrentStatus?> GetTorrentStatus(string hash)
|
||||
{
|
||||
return await _client.SendRequest<TorrentStatus?>(
|
||||
"web.get_torrent_status",
|
||||
hash,
|
||||
new[] { "hash", "state", "name", "eta" }
|
||||
);
|
||||
}
|
||||
|
||||
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory> contents, Action<string, DelugeFileOrDirectory> processFile)
|
||||
{
|
||||
foreach (var (name, data) in contents)
|
||||
{
|
||||
switch (data.Type)
|
||||
{
|
||||
case "file":
|
||||
processFile(name, data);
|
||||
break;
|
||||
case "dir" when data.Contents is not null:
|
||||
// Recurse into subdirectories
|
||||
ProcessFiles(data.Contents, processFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge.Extensions;
|
||||
|
||||
internal static class DelugeExtensions
|
||||
{
|
||||
public static List<String?> GetAllJsonPropertyFromType(this Type t)
|
||||
{
|
||||
var type = typeof(JsonPropertyAttribute);
|
||||
var props = t.GetProperties()
|
||||
.Where(prop => Attribute.IsDefined(prop, type))
|
||||
.ToList();
|
||||
|
||||
return props
|
||||
.Select(x => x.GetCustomAttributes(type, true).Single())
|
||||
.Cast<JsonPropertyAttribute>()
|
||||
.Select(x => x.PropertyName)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public abstract class DownloadServiceBase : IDownloadService
|
||||
{
|
||||
protected readonly ILogger<DownloadServiceBase> _logger;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly FilenameEvaluator _filenameEvaluator;
|
||||
protected readonly Striker _striker;
|
||||
|
||||
protected DownloadServiceBase(
|
||||
ILogger<DownloadServiceBase> logger,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||
_filenameEvaluator = filenameEvaluator;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public abstract void Dispose();
|
||||
|
||||
public abstract Task LoginAsync();
|
||||
|
||||
public abstract Task<bool> ShouldRemoveFromArrQueueAsync(string hash);
|
||||
|
||||
public abstract Task BlockUnwantedFilesAsync(string hash);
|
||||
|
||||
protected bool StrikeAndCheckLimit(string hash, string itemName)
|
||||
{
|
||||
return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public sealed class DownloadServiceFactory
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly Domain.Enums.DownloadClient _downloadClient;
|
||||
|
||||
public DownloadServiceFactory(IServiceProvider serviceProvider, IConfiguration configuration)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_downloadClient = (Domain.Enums.DownloadClient)Enum.Parse(
|
||||
typeof(Domain.Enums.DownloadClient),
|
||||
configuration[EnvironmentVariables.DownloadClient] ?? Domain.Enums.DownloadClient.QBittorrent.ToString(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public IDownloadService CreateDownloadClient() =>
|
||||
_downloadClient switch
|
||||
{
|
||||
Domain.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService<QBitService>(),
|
||||
Domain.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(),
|
||||
Domain.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(),
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public interface IDownloadService : IDisposable
|
||||
{
|
||||
public Task LoginAsync();
|
||||
|
||||
public Task<bool> ShouldRemoveFromArrQueueAsync(string hash);
|
||||
|
||||
public Task BlockUnwantedFilesAsync(string hash);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
public sealed class QBitService : DownloadServiceBase
|
||||
{
|
||||
private readonly QBitConfig _config;
|
||||
private readonly QBittorrentClient _client;
|
||||
|
||||
public QBitService(
|
||||
ILogger<QBitService> logger,
|
||||
IOptions<QBitConfig> config,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
_config = config.Value;
|
||||
_config.Validate();
|
||||
_client = new(_config.Url);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.LoginAsync(_config.Username, _config.Password);
|
||||
}
|
||||
|
||||
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
// if all files were blocked by qBittorrent
|
||||
if (torrent is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
// if all files are marked as skip
|
||||
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsItemStuckAndShouldRemove(torrent);
|
||||
}
|
||||
|
||||
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||
{
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip || _filenameEvaluator.IsValid(file.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Name);
|
||||
await _client.SetFilePriorityAsync(hash, file.Index.Value, TorrentContentPriority.Skip);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||
{
|
||||
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(torrent.Hash, torrent.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Transmission.API.RPC;
|
||||
using Transmission.API.RPC.Arguments;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
|
||||
public sealed class TransmissionService : DownloadServiceBase
|
||||
{
|
||||
private readonly TransmissionConfig _config;
|
||||
private readonly Client _client;
|
||||
private TorrentInfo[]? _torrentsCache;
|
||||
|
||||
public TransmissionService(
|
||||
ILogger<TransmissionService> logger,
|
||||
IOptions<TransmissionConfig> config,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
_config = config.Value;
|
||||
_config.Validate();
|
||||
_client = new(
|
||||
new Uri(_config.Url, "/transmission/rpc").ToString(),
|
||||
login: _config.Username,
|
||||
password: _config.Password
|
||||
);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.GetSessionInformationAsync();
|
||||
}
|
||||
|
||||
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool shouldRemove = torrent.FileStats?.Length > 0;
|
||||
|
||||
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
|
||||
{
|
||||
if (!stats.Wanted.HasValue)
|
||||
{
|
||||
// if any files stats are missing, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
|
||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||
{
|
||||
// if any files are wanted, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
}
|
||||
|
||||
// remove if all files are unwanted
|
||||
return shouldRemove || IsItemStuckAndShouldRemove(torrent);
|
||||
}
|
||||
|
||||
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent?.FileStats is null || torrent.Files is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<long> unwantedFiles = [];
|
||||
|
||||
for (int i = 0; i < torrent.Files.Length; i++)
|
||||
{
|
||||
if (torrent.FileStats?[i].Wanted == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!torrent.FileStats[i].Wanted.Value || _filenameEvaluator.IsValid(torrent.Files[i].Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", torrent.Files[i].Name);
|
||||
unwantedFiles.Add(i);
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
await _client.TorrentSetAsync(new TorrentSettings
|
||||
{
|
||||
Ids = [ torrent.Id ],
|
||||
FilesUnwanted = unwantedFiles.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||
{
|
||||
if (torrent.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return false;
|
||||
}
|
||||
|
||||
if (torrent.Eta > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!);
|
||||
}
|
||||
|
||||
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = _torrentsCache?
|
||||
.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (_torrentsCache is null || torrent is null)
|
||||
{
|
||||
string[] fields = [
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS
|
||||
];
|
||||
|
||||
// refresh cache
|
||||
_torrentsCache = (await _client.TorrentGetAsync(fields))
|
||||
?.Torrents;
|
||||
}
|
||||
|
||||
if (_torrentsCache?.Length is null or 0)
|
||||
{
|
||||
_logger.LogDebug("could not list torrents | {url}", _config.Url);
|
||||
}
|
||||
|
||||
torrent = _torrentsCache?.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("could not find torrent | {hash} | {url}", hash, _config.Url);
|
||||
}
|
||||
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
57
code/Infrastructure/Verticals/ItemStriker/Striker.cs
Normal file
57
code/Infrastructure/Verticals/ItemStriker/Striker.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.ItemStriker;
|
||||
|
||||
public class Striker
|
||||
{
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
|
||||
public Striker(ILogger<Striker> logger, IMemoryCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(TimeSpan.FromHours(2));
|
||||
}
|
||||
|
||||
public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
|
||||
{
|
||||
if (maxStrikes is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string key = $"{strikeType.ToString()}_{hash}";
|
||||
|
||||
if (!_cache.TryGetValue(key, out int? strikeCount))
|
||||
{
|
||||
strikeCount = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
++strikeCount;
|
||||
}
|
||||
|
||||
_logger.LogDebug("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
|
||||
_cache.Set(key, strikeCount, _cacheOptions);
|
||||
|
||||
if (strikeCount < maxStrikes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strikeCount > maxStrikes)
|
||||
{
|
||||
_logger.LogWarning("blocked item keeps coming back | {name}", itemName);
|
||||
_logger.LogWarning("be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items");
|
||||
}
|
||||
|
||||
_logger.LogInformation("removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
125
code/Infrastructure/Verticals/Jobs/GenericHandler.cs
Normal file
125
code/Infrastructure/Verticals/Jobs/GenericHandler.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Jobs;
|
||||
|
||||
public abstract class GenericHandler : IDisposable
|
||||
{
|
||||
protected readonly ILogger<GenericHandler> _logger;
|
||||
protected readonly SonarrConfig _sonarrConfig;
|
||||
protected readonly RadarrConfig _radarrConfig;
|
||||
protected readonly SonarrClient _sonarrClient;
|
||||
protected readonly RadarrClient _radarrClient;
|
||||
protected readonly ArrQueueIterator _arrArrQueueIterator;
|
||||
protected readonly IDownloadService _downloadService;
|
||||
|
||||
protected GenericHandler(
|
||||
ILogger<GenericHandler> logger,
|
||||
SonarrConfig sonarrConfig,
|
||||
RadarrConfig radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_sonarrConfig = sonarrConfig;
|
||||
_radarrConfig = radarrConfig;
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
_arrArrQueueIterator = arrArrQueueIterator;
|
||||
_downloadService = downloadServiceFactory.CreateDownloadClient();
|
||||
}
|
||||
|
||||
public virtual async Task ExecuteAsync()
|
||||
{
|
||||
await _downloadService.LoginAsync();
|
||||
|
||||
await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr);
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_downloadService.Dispose();
|
||||
}
|
||||
|
||||
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
|
||||
|
||||
private async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ArrInstance arrInstance in config.Instances)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessInstanceAsync(arrInstance, instanceType);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected ArrClient GetClient(InstanceType type) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => _sonarrClient,
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
|
||||
protected ArrConfig GetConfig(InstanceType type) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => _sonarrConfig,
|
||||
InstanceType.Radarr => _radarrConfig,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
|
||||
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && !isPack => new SonarrSearchItem
|
||||
{
|
||||
Id = record.EpisodeId,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Episode
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && isPack => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Season
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Series
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeriesId,
|
||||
},
|
||||
InstanceType.Radarr => new SearchItem
|
||||
{
|
||||
Id = record.MovieId,
|
||||
},
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
}
|
||||
35
code/Infrastructure/Verticals/Jobs/JobChainingListener.cs
Normal file
35
code/Infrastructure/Verticals/Jobs/JobChainingListener.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Quartz;
|
||||
|
||||
namespace Infrastructure.Verticals.Jobs;
|
||||
|
||||
public class JobChainingListener : IJobListener
|
||||
{
|
||||
private readonly string _nextJobName;
|
||||
|
||||
public JobChainingListener(string nextJobName)
|
||||
{
|
||||
_nextJobName = nextJobName;
|
||||
}
|
||||
|
||||
public string Name => nameof(JobChainingListener);
|
||||
|
||||
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IScheduler scheduler = context.Scheduler;
|
||||
JobKey nextJobKey = new(_nextJobName);
|
||||
|
||||
if (await scheduler.CheckExists(nextJobKey, cancellationToken))
|
||||
{
|
||||
await scheduler.TriggerJob(nextJobKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs
Normal file
73
code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
public sealed class QueueCleaner : GenericHandler
|
||||
{
|
||||
public QueueCleaner(
|
||||
ILogger<QueueCleaner> logger,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(logger, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
HashSet<SearchItem> itemsToBeRefreshed = [];
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
ArrConfig arrConfig = GetConfig(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueRecord record = group.First();
|
||||
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!arrClient.IsRecordValid(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!arrClient.ShouldRemoveFromQueue(record) && !await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
|
||||
{
|
||||
_logger.LogInformation("skip | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
|
||||
|
||||
await arrClient.DeleteQueueItemAsync(instance, record);
|
||||
}
|
||||
});
|
||||
|
||||
await arrClient.RefreshItemsAsync(instance, arrConfig, itemsToBeRefreshed);
|
||||
}
|
||||
}
|
||||
2
code/test/data/cleanuperr/config/blacklist
Normal file
2
code/test/data/cleanuperr/config/blacklist
Normal file
@@ -0,0 +1,2 @@
|
||||
.*sample.*
|
||||
*.zipx
|
||||
1
code/test/data/cleanuperr/config/whitelist
Normal file
1
code/test/data/cleanuperr/config/whitelist
Normal file
@@ -0,0 +1 @@
|
||||
*.mkv
|
||||
Binary file not shown.
1
code/test/data/deluge/config/auth
Normal file
1
code/test/data/deluge/config/auth
Normal file
@@ -0,0 +1 @@
|
||||
localclient:da4d4b43be734d48c1bb8b9ab0e39894520994e3:10
|
||||
15
code/test/data/deluge/config/blocklist.conf
Normal file
15
code/test/data/deluge/config/blocklist.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"file": 1,
|
||||
"format": 1
|
||||
}{
|
||||
"check_after_days": 4,
|
||||
"last_update": 0.0,
|
||||
"list_compression": "",
|
||||
"list_size": 0,
|
||||
"list_type": "",
|
||||
"load_on_start": false,
|
||||
"timeout": 180,
|
||||
"try_times": 3,
|
||||
"url": "",
|
||||
"whitelisted": []
|
||||
}
|
||||
97
code/test/data/deluge/config/core.conf
Normal file
97
code/test/data/deluge/config/core.conf
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"file": 1,
|
||||
"format": 1
|
||||
}{
|
||||
"add_paused": false,
|
||||
"allow_remote": false,
|
||||
"auto_manage_prefer_seeds": false,
|
||||
"auto_managed": true,
|
||||
"cache_expiry": 60,
|
||||
"cache_size": 512,
|
||||
"copy_torrent_file": false,
|
||||
"daemon_port": 58846,
|
||||
"del_copy_torrent_file": false,
|
||||
"dht": true,
|
||||
"dont_count_slow_torrents": false,
|
||||
"download_location": "/downloads",
|
||||
"download_location_paths_list": [],
|
||||
"enabled_plugins": [
|
||||
"Label"
|
||||
],
|
||||
"enc_in_policy": 1,
|
||||
"enc_level": 2,
|
||||
"enc_out_policy": 1,
|
||||
"geoip_db_location": "/usr/share/GeoIP/GeoIP.dat",
|
||||
"ignore_limits_on_local_network": true,
|
||||
"info_sent": 0.0,
|
||||
"listen_interface": "",
|
||||
"listen_ports": [
|
||||
6882,
|
||||
6882
|
||||
],
|
||||
"listen_random_port": null,
|
||||
"listen_reuse_port": true,
|
||||
"listen_use_sys_port": false,
|
||||
"lsd": true,
|
||||
"max_active_downloading": 3,
|
||||
"max_active_limit": 8,
|
||||
"max_active_seeding": 5,
|
||||
"max_connections_global": 200,
|
||||
"max_connections_per_second": 20,
|
||||
"max_connections_per_torrent": -1,
|
||||
"max_download_speed": -1.0,
|
||||
"max_download_speed_per_torrent": -1,
|
||||
"max_half_open_connections": 50,
|
||||
"max_upload_slots_global": 4,
|
||||
"max_upload_slots_per_torrent": -1,
|
||||
"max_upload_speed": -1.0,
|
||||
"max_upload_speed_per_torrent": -1,
|
||||
"move_completed": false,
|
||||
"move_completed_path": "/downloads",
|
||||
"move_completed_paths_list": [],
|
||||
"natpmp": true,
|
||||
"new_release_check": true,
|
||||
"outgoing_interface": "",
|
||||
"outgoing_ports": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"path_chooser_accelerator_string": "Tab",
|
||||
"path_chooser_auto_complete_enabled": true,
|
||||
"path_chooser_max_popup_rows": 20,
|
||||
"path_chooser_show_chooser_button_on_localhost": true,
|
||||
"path_chooser_show_hidden_files": false,
|
||||
"peer_tos": "0x00",
|
||||
"plugins_location": "/config/plugins",
|
||||
"pre_allocate_storage": false,
|
||||
"prioritize_first_last_pieces": false,
|
||||
"proxy": {
|
||||
"anonymous_mode": false,
|
||||
"force_proxy": false,
|
||||
"hostname": "",
|
||||
"password": "",
|
||||
"port": 8080,
|
||||
"proxy_hostnames": true,
|
||||
"proxy_peer_connections": true,
|
||||
"proxy_tracker_connections": true,
|
||||
"type": 0,
|
||||
"username": ""
|
||||
},
|
||||
"queue_new_to_top": false,
|
||||
"random_outgoing_ports": true,
|
||||
"random_port": false,
|
||||
"rate_limit_ip_overhead": false,
|
||||
"remove_seed_at_ratio": false,
|
||||
"seed_time_limit": 180,
|
||||
"seed_time_ratio_limit": 7.0,
|
||||
"send_info": false,
|
||||
"sequential_download": false,
|
||||
"share_ratio_limit": 2.0,
|
||||
"shared": false,
|
||||
"stop_seed_at_ratio": false,
|
||||
"stop_seed_ratio": 2.0,
|
||||
"super_seeding": false,
|
||||
"torrentfiles_location": "/config/torrents",
|
||||
"upnp": true,
|
||||
"utpex": true
|
||||
}
|
||||
14
code/test/data/deluge/config/hostlist.conf
Normal file
14
code/test/data/deluge/config/hostlist.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"file": 3,
|
||||
"format": 1
|
||||
}{
|
||||
"hosts": [
|
||||
[
|
||||
"b5408e9794dd432789c55d8c46d15275",
|
||||
"127.0.0.1",
|
||||
58846,
|
||||
"localclient",
|
||||
"da4d4b43be734d48c1bb8b9ab0e39894520994e3"
|
||||
]
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user