Compare commits

...

37 Commits

Author SHA1 Message Date
Marius Nechifor
43a11f0e4c added Serilog and file logging (#17) 2024-11-28 23:12:08 +02:00
Marius Nechifor
a5a54e324d fixed faulty regex detection and concurrent data accessing (#16) 2024-11-28 23:05:29 +02:00
Marius Nechifor
53adb6c1c1 fixed queue cleaner being triggered perpetually after each run (#15) 2024-11-25 21:53:17 +02:00
Marius Nechifor
a0c8ff72fb Trigger queue cleaner sequentially (#14)
* added option to run queue cleaner after content blocker

* updated readme to clearly state what the jobs do
2024-11-25 21:33:06 +02:00
Marius Nechifor
599242aa2a added startup job trigger (#12) 2024-11-24 01:47:01 +02:00
Marius Nechifor
3e0913b437 Fix empty torrents (#11)
* fixed unwanted deletion of torrents in downloading metadata state

* refactored jobs code

* updated arr test data

* updated gitignore

* updated test configuration and removed dispensable files
2024-11-24 01:01:20 +02:00
Marius Nechifor
54cabd98b4 remove empty creds restriction (#10)
* removed empty checks on qbit and deluge credentials

* updated configuration readme
2024-11-19 23:54:16 +02:00
Marius Nechifor
cbc5c571b3 fixed missing torrent check on content blocker (#9) 2024-11-19 23:23:22 +02:00
Marius Nechifor
beea640d49 fixed content blocker crashing on empty download id (#8) 2024-11-19 23:02:43 +02:00
Marius Nechifor
65b8a7f988 removed Transmission validation on empty credentials (#6) 2024-11-19 13:46:40 +02:00
Flaminel
b86173d6c0 updated readme with more details 2024-11-19 00:16:43 +02:00
Flaminel
09c4b2a28e added permissive blacklist 2024-11-19 00:16:20 +02:00
Flaminel
6e8545e4ca updated readme to include a short description 2024-11-19 00:05:38 +02:00
Marius Nechifor
e0a6c7842b add content blocker (#5)
* refactored code
added deluge support
added transmission support
added content blocker
added blacklist and whitelist

* increased level on some logs; updated test docker compose; updated dev appsettings

* updated docker compose and readme

* moved some logs

* fixed env var typo; fixed sonarr and radarr default download client
2024-11-18 20:08:01 +02:00
Flaminel
b323cb40ae added blocklists 2024-11-18 17:34:23 +02:00
Flaminel
95a35f9988 updated readme with Windows Service instructions 2024-11-15 08:24:34 +02:00
Flaminel
a7f3bad191 updated readme to make it clear what binaries are for 2024-11-15 08:24:30 +02:00
Flaminel
ed20b67b92 added readme discord server announcement 2024-11-14 22:58:23 +02:00
Manuel González Martínez
42a3f75d94 Update README.md (#4)
Added docker compose yaml
2024-11-14 22:43:02 +02:00
Flaminel
6de882d12e changed image tag in readme 2024-11-14 22:27:04 +02:00
Flaminel
6587014e8d fixed exiting after one torrent processed 2024-11-14 22:24:01 +02:00
Marius Nechifor
36a07b251a add test environment 2024-11-14 13:23:49 +02:00
Marius Nechifor
c48eed7f77 create LICENSE 2024-11-14 10:41:12 +02:00
Flaminel
48f3c3b35b fixed readme 2024-11-14 09:14:19 +02:00
Flaminel
0d6f62dd70 disabled main branch pipeline; enabled pipeline on tag 2024-11-13 22:43:10 +02:00
Flaminel
77cc5c99ed updated readme 2024-11-13 22:39:57 +02:00
Marius Nechifor
513134fd65 added support for Radarr 2024-11-13 22:37:00 +02:00
Flaminel
906be45758 enabled pull request builds 2024-11-13 10:22:05 +02:00
Flaminel
c4a15e77e4 fixed project and output name missmatch 2024-11-12 18:43:00 +02:00
Flaminel
baffdfdd5a revert pipeline branch filter 2024-11-12 16:54:00 +02:00
Flaminel
827afb5a4d disabled branch pipeline filter 2024-11-12 15:57:21 +02:00
Flaminel
26939b2cd3 fixed readme typo 2024-11-12 13:18:34 +02:00
Flaminel
34d05c5416 updated extensions list 2024-11-12 09:34:58 +02:00
Flaminel
b9376e02fa fixed size data type 2024-11-12 08:59:10 +02:00
Flaminel
2d5c59ddba changed release workflow to use universal-workflows 2024-11-11 15:27:16 +02:00
Flaminel
11864617de updated readme 2024-11-11 11:01:32 +02:00
Flaminel
d5bff76a62 added custom assembly name 2024-11-11 10:37:17 +02:00
180 changed files with 5164 additions and 559 deletions

View File

@@ -8,4 +8,5 @@ jobs:
with:
dockerRepository: flaminel/cleanuperr
githubContext: ${{ toJSON(github) }}
outputName: cleanuperr
secrets: inherit

View File

@@ -1,8 +1,13 @@
on:
push:
tags:
- "v*.*.*"
# paths:
# - 'code/**'
# branches: [ main ]
pull_request:
paths:
- 'code/**'
branches: [ main ]
jobs:
build:

View File

@@ -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
View File

@@ -165,3 +165,10 @@ src/.idea/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/
**/logs/
**/MediaCover/
**/archive/
**/Backups/
*.fastresume
*.bak

21
LICENSE Normal file
View 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.

319
README.md
View File

@@ -1,143 +1,212 @@
# 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.
## Important note
Only the **latest versions** of qBittorrent, Deluge, Sonarr etc. are supported, or earlier versions that have the same API as the latest version.
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 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:
- It will be removed from the *arr's queue.
- 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
```
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" \
docker run -d \
-e TRIGGERS__QUEUECLEANER="0 0/5 * * * ?" \
-e QBITTORRENT__ENABLED=true \
-e QBITTORRENT__URL="http://localhost:8080" \
-e QBITTORRENT__USERNAME="user" \
-e QBITTORRENT__PASSWORD="pass" \
-e SONARR__ENABLED=true \
-e SONARR__INSTANCES__0__URL="http://localhost:8989" \
-e SONARR__INSTANCES__0__APIKEY="secret1" \
-e SONARR__INSTANCES__1__URL="http://localhost:8990" \
-e SONARR__INSTANCES__1__APIKEY="secret2" \
-e RADARR__ENABLED=true \
-e RADARR__INSTANCES__0__URL="http://localhost:7878" \
-e RADARR__INSTANCES__0__APIKEY="secret3" \
-e RADARR__INSTANCES__1__URL="http://localhost:7879" \
-e RADARR__INSTANCES__1__APIKEY="secret4" \
...
flaminel/cleanuperr:latest
```
## Environment variables
### Docker compose yaml
```
version: "3.3"
services:
cleanuperr:
volumes:
- ./cleanuperr/logs:/var/logs
environment:
- LOGGING__LOGLEVEL=Information
- LOGGING__FILE__ENABLED=false
- LOGGING__FILE__PATH=/var/logs/
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
- QUEUECLEANER__ENABLED=true
- QUEUECLEANER__RUNSEQUENTIALLY=true
- 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
- QBITTORRENT__ENABLED=true
- QBITTORRENT__URL=http://localhost:8080
- QBITTORRENT__USERNAME=user
- QBITTORRENT__PASSWORD=pass
# OR
# - DELUGE__ENABLED=true
# - DELUGE__URL=http://localhost:8112
# - DELUGE__PASSWORD=testing
# OR
# - TRANSMISSION__ENABLED=true
# - TRANSMISSION__URL=http://localhost:9091
# - TRANSMISSION__USERNAME=test
# - TRANSMISSION__PASSWORD=testing
- SONARR__ENABLED=true
- 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__INSTANCES__0__URL=http://localhost:7878
- RADARR__INSTANCES__0__APIKEY=secret3
- RADARR__INSTANCES__1__URL=http://localhost:7879
- RADARR__INSTANCES__1__APIKEY=secret4
image: flaminel/cleanuperr:latest
restart: unless-stopped
```
### 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 |
|||||
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html) | 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) | 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 |
|||||
| 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); 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); Needs to be json compatible | empty |
|||||
| QBITTORRENT__ENABLED | No | Enable or disable qBittorrent | true |
| QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 |
| QBITTORRENT__USERNAME | No | qBittorrent user | empty |
| QBITTORRENT__PASSWORD | No | qBittorrent password | empty |
|||||
| DELUGE__ENABLED | No | Enable or disable Deluge | false |
| DELUGE__URL | No | Deluge instance url | http://localhost:8080 |
| DELUGE__PASSWORD | No | Deluge password | empty |
|||||
| TRANSMISSION__ENABLED | No | Enable or disable Transmission | true |
| 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__INSTANCES__0__URL | Yes | First Sonarr instance url | http://localhost:8989 |
| SONARR__INSTANCES__0__APIKEY | Yes | First Sonarr instance API key | empty |
|||||
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
| RADARR__INSTANCES__0__URL | Yes | First Radarr instance url | http://localhost:8989 |
| RADARR__INSTANCES__0__APIKEY | Yes | 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
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>
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678

519
blacklist Normal file
View 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
View 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

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.1.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
namespace Common.Configuration;
public abstract record ArrConfig
{
public required bool Enabled { get; init; }
public required List<ArrInstance> Instances { get; init; }
}

View File

@@ -1,6 +1,6 @@
namespace Common.Configuration;
public sealed class SonarrInstance
public sealed class ArrInstance
{
public required Uri Url { get; set; }

View File

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

View File

@@ -0,0 +1,8 @@
namespace Common.Configuration.ContentBlocker;
public sealed record PatternConfig
{
public bool Enabled { get; init; }
public string? Path { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System.Security;
namespace Common.Configuration;
public sealed record DelugeConfig : IConfig
{
public const string SectionName = "Deluge";
public required bool Enabled { get; init; }
public Uri? Url { get; init; }
public string? Password { get; init; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (Url is null)
{
throw new ArgumentNullException(nameof(Url));
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Common.Configuration;
public interface IConfig
{
void Validate();
}

View File

@@ -0,0 +1,6 @@
namespace Common.Configuration;
public interface IJobConfig : IConfig
{
bool Enabled { get; init; }
}

View 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()
{
}
}

View File

@@ -0,0 +1,16 @@
using Serilog.Events;
namespace Common.Configuration.Logging;
public class LoggingConfig : IConfig
{
public const string SectionName = "Logging";
public LogEventLevel LogLevel { get; set; }
public FileLogConfig? File { get; set; }
public void Validate()
{
}
}

View File

@@ -1,10 +1,29 @@
namespace Common.Configuration;
using System.ComponentModel.DataAnnotations;
public sealed class QBitConfig
namespace Common.Configuration;
public sealed class QBitConfig : IConfig
{
public required Uri Url { get; set; }
public const string SectionName = "qBittorrent";
public required string Username { get; set; }
public required bool Enabled { get; init; }
public required string Password { get; set; }
public Uri? Url { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (Url is null)
{
throw new ArgumentNullException(nameof(Url));
}
}
}

View File

@@ -1,6 +0,0 @@
namespace Common.Configuration;
public sealed class QuartzConfig
{
public required string BlockedTorrentTrigger { get; init; }
}

View File

@@ -0,0 +1,14 @@
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; }
public void Validate()
{
}
}

View File

@@ -0,0 +1,6 @@
namespace Common.Configuration;
public sealed record RadarrConfig : ArrConfig
{
public const string SectionName = "Radarr";
}

View File

@@ -1,6 +1,6 @@
namespace Common.Configuration;
public sealed class SonarrConfig
public sealed record SonarrConfig : ArrConfig
{
public required List<SonarrInstance> Instances { get; set; }
public const string SectionName = "Sonarr";
}

View File

@@ -0,0 +1,27 @@
namespace Common.Configuration;
public record TransmissionConfig
{
public const string SectionName = "Transmission";
public required bool Enabled { get; init; }
public Uri? Url { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (Url is null)
{
throw new ArgumentNullException(nameof(Url));
}
}
}

View 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; }
}

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
namespace Domain.Enums;
public enum BlocklistType
{
Blacklist,
Whitelist
}

View File

@@ -0,0 +1,9 @@
namespace Domain.Enums;
public enum InstanceType
{
Sonarr,
Radarr,
Lidarr,
Readarr
}

View File

@@ -0,0 +1,7 @@
namespace Domain.Arr.Queue;
public record QueueListResponse
{
public required int TotalRecords { get; init; }
public required IReadOnlyList<QueueRecord> Records { get; init; }
}

View File

@@ -0,0 +1,15 @@
namespace Domain.Arr.Queue;
public record QueueRecord
{
public int SeriesId { get; init; }
public int EpisodeId { 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; }
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Models.Deluge.Exceptions;
public class DelugeClientException : Exception
{
public DelugeClientException(string message) : base(message)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Models.Deluge.Exceptions;
public sealed class DelugeLoginException : DelugeClientException
{
public DelugeLoginException() : base("login failed")
{
}
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Models.Deluge.Exceptions;
public sealed class DelugeLogoutException : DelugeClientException
{
public DelugeLogoutException() : base("logout failed")
{
}
}

View 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;
}
}

View 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
}

View 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; }
}

View 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; }
}

View File

@@ -0,0 +1,6 @@
namespace Domain.Models.Deluge.Response;
public sealed record DelugeMinimalStatus
{
public string? Hash { get; set; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Models.Radarr;
public sealed record RadarrCommand
{
public required string Name { get; init; }
public required HashSet<int> MovieIds { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Models.Sonarr;
public sealed record SonarrCommand
{
public required string Name { get; init; }
public required int SeriesId { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace Domain.Sonarr.Queue;
public record CustomFormat(
int Id,
string Name
);

View File

@@ -1,6 +0,0 @@
namespace Domain.Sonarr.Queue;
public record Language(
int Id,
string Name
);

View File

@@ -1,10 +0,0 @@
namespace Domain.Sonarr.Queue;
public record QueueListResponse(
int Page,
int PageSize,
string SortKey,
string SortDirection,
int TotalRecords,
IReadOnlyList<Record> Records
);

View File

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

View File

@@ -1,7 +0,0 @@
namespace Domain.Sonarr.Queue;
public record Revision(
int Version,
int Real,
bool IsRepack
);

View File

@@ -1,6 +0,0 @@
namespace Domain.Sonarr.Queue;
public record StatusMessage(
string Title,
IReadOnlyList<string> Messages
);

View File

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

View File

@@ -0,0 +1,16 @@
using Common.Configuration;
using Common.Configuration.ContentBlocker;
namespace Executable.DependencyInjection;
public static class ConfigurationDI
{
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
services
.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));
}

View File

@@ -0,0 +1,66 @@
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("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();
}
}

View File

@@ -0,0 +1,50 @@
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)
.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;
}
}

View File

@@ -0,0 +1,114 @@
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;
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;
}
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();
});
}
}

View File

@@ -0,0 +1,27 @@
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 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>();
}

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<AssemblyName>cleanuperr</AssemblyName>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -8,11 +9,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<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.1.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -0,0 +1,34 @@
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);
}
}
}

View File

@@ -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();

View File

@@ -1,11 +1,63 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
"LogLevel": "Debug",
"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
},
"qBittorrent": {
"Enabled": true,
"Url": "http://localhost:8080",
"Username": "test",
"Password": "testing"
},
"Deluge": {
"Enabled": false,
"Url": "http://localhost:8112",
"Password": "testing"
},
"Transmission": {
"Enabled": false,
"Url": "http://localhost:9091",
"Username": "test",
"Password": "testing"
},
"Sonarr": {
"Enabled": true,
"Instances": [
{
"Url": "http://localhost:8989",
"ApiKey": "96736c3eb3144936b8f1d62d27be8cee"
}
]
},
"Radarr": {
"Enabled": true,
"Instances": [
{
"Url": "http://localhost:7878",
"ApiKey": "705b553732ab4167ab23909305d60600"
}
]
}
}

View File

@@ -1,26 +1,63 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information",
"Quartz": "Warning",
"System.Net.Http.HttpClient": "Error"
"LogLevel": "Information",
"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
},
"qBittorrent": {
"Enabled": true,
"Url": "http://localhost:8080",
"Username": "",
"Password": ""
},
"SonarrConfig": {
"Deluge": {
"Enabled": false,
"Url": "http://localhost:8112",
"Password": "testing"
},
"Transmission": {
"Enabled": false,
"Url": "http://localhost:9091",
"Username": "test",
"Password": "testing"
},
"Sonarr": {
"Enabled": true,
"Instances": [
{
"Url": "http://localhost:8989",
"ApiKey": ""
}
]
},
"Radarr": {
"Enabled": false,
"Instances": [
{
"Url": "http://localhost:7878",
"ApiKey": ""
}
]
}
}

View File

@@ -12,9 +12,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FLM.Transmission" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
<PackageReference Include="Quartz" Version="3.13.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,77 @@
using Common.Configuration;
using Domain.Arr.Queue;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public abstract class ArrClient
{
private protected ILogger<ArrClient> _logger;
private protected HttpClient _httpClient;
protected ArrClient(ILogger<ArrClient> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient();
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{
Uri uri = new(arrInstance.Url, $"/api/v3/queue?page={page}&pageSize=200&sortKey=timeleft");
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 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, HashSet<int> itemIds);
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{
request.Headers.Add("x-api-key", apiKey);
}
}

View File

@@ -0,0 +1,53 @@
using Common.Configuration;
using Domain.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);
}
}

View File

@@ -0,0 +1,52 @@
using System.Text;
using Common.Configuration;
using Domain.Models.Radarr;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public sealed class RadarrClient : ArrClient
{
public RadarrClient(ILogger<ArrClient> logger, IHttpClientFactory httpClientFactory)
: base(logger, httpClientFactory)
{
}
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<int> itemIds)
{
if (itemIds.Count is 0)
{
return;
}
Uri uri = new(arrInstance.Url, "/api/v3/command");
RadarrCommand command = new()
{
Name = "MoviesSearch",
MovieIds = itemIds
};
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);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("movie search triggered | {url} | movie ids: {ids}", arrInstance.Url, string.Join(",", itemIds));
}
catch
{
_logger.LogError("movie search failed | {url} | movie ids: {ids}", arrInstance.Url, string.Join(",", itemIds));
throw;
}
}
}

View File

@@ -0,0 +1,50 @@
using System.Text;
using Common.Configuration;
using Domain.Models.Sonarr;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public sealed class SonarrClient : ArrClient
{
public SonarrClient(ILogger<SonarrClient> logger, IHttpClientFactory httpClientFactory)
: base(logger, httpClientFactory)
{
}
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<int> itemIds)
{
foreach (int itemId in itemIds)
{
Uri uri = new(arrInstance.Url, "/api/v3/command");
SonarrCommand command = new()
{
Name = "SeriesSearch",
SeriesId = itemId
};
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);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("series search triggered | {url} | series id: {id}", arrInstance.Url, itemId);
}
catch
{
_logger.LogError("series search failed | {url} | series id: {id}", arrInstance.Url, itemId);
throw;
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
using Common.Configuration;
using Domain.Arr.Queue;
using Domain.Enums;
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);
}
});
}
private ArrClient GetClient(InstanceType type) =>
type switch
{
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

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

View File

@@ -0,0 +1,138 @@
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
using Common.Configuration;
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;
}
}

View File

@@ -0,0 +1,165 @@
using Common.Configuration;
using Domain.Models.Deluge.Response;
using Infrastructure.Verticals.ContentBlocker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient.Deluge;
public sealed class DelugeService : IDownloadService
{
private readonly ILogger<DelugeService> _logger;
private readonly DelugeClient _client;
private readonly FilenameEvaluator _filenameEvaluator;
public DelugeService(
ILogger<DelugeService> logger,
IOptions<DelugeConfig> config,
IHttpClientFactory httpClientFactory,
FilenameEvaluator filenameEvaluator
)
{
_logger = logger;
_client = new (config, httpClientFactory);
_filenameEvaluator = filenameEvaluator;
}
public async Task LoginAsync()
{
await _client.LoginAsync();
}
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
{
hash = hash.ToLowerInvariant();
DelugeContents? contents = null;
if (!await HasMinimalStatus(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);
}
// if no files found, torrent might be stuck in Downloading metadata
if (contents?.Contents?.Count is null or 0)
{
return false;
}
bool shouldRemove = true;
ProcessFiles(contents.Contents, (_, file) =>
{
if (file.Priority > 0)
{
shouldRemove = false;
}
});
return shouldRemove;
}
public async Task BlockUnwantedFilesAsync(string hash)
{
hash = hash.ToLowerInvariant();
if (!await HasMinimalStatus(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 async Task<bool> HasMinimalStatus(string hash)
{
DelugeMinimalStatus? status = await _client.SendRequest<DelugeMinimalStatus?>(
"web.get_torrent_status",
hash,
new[] { "hash" }
);
if (status?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false;
}
return true;
}
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 void Dispose()
{
}
}

View File

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

View File

@@ -0,0 +1,65 @@
using Common.Configuration;
using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.DownloadClient.QBittorrent;
using Infrastructure.Verticals.DownloadClient.Transmission;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient;
public sealed class DownloadServiceFactory
{
private readonly QBitConfig _qBitConfig;
private readonly DelugeConfig _delugeConfig;
private readonly TransmissionConfig _transmissionConfig;
private readonly IServiceProvider _serviceProvider;
public DownloadServiceFactory(
IOptions<QBitConfig> qBitConfig,
IOptions<DelugeConfig> delugeConfig,
IOptions<TransmissionConfig> transmissionConfig,
IServiceProvider serviceProvider)
{
_qBitConfig = qBitConfig.Value;
_delugeConfig = delugeConfig.Value;
_transmissionConfig = transmissionConfig.Value;
_serviceProvider = serviceProvider;
_qBitConfig.Validate();
_delugeConfig.Validate();
_transmissionConfig.Validate();
int enabledCount = new[] { _qBitConfig.Enabled, _delugeConfig.Enabled, _transmissionConfig.Enabled }
.Count(enabled => enabled);
if (enabledCount > 1)
{
throw new Exception("only one download client can be enabled");
}
if (enabledCount == 0)
{
throw new Exception("no download client is enabled");
}
}
public IDownloadService CreateDownloadClient()
{
if (_qBitConfig.Enabled)
{
return _serviceProvider.GetRequiredService<QBitService>();
}
if (_delugeConfig.Enabled)
{
return _serviceProvider.GetRequiredService<DelugeService>();
}
if (_transmissionConfig.Enabled)
{
return _serviceProvider.GetRequiredService<TransmissionService>();
}
throw new NotSupportedException();
}
}

View File

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

View File

@@ -0,0 +1,96 @@
using Common.Configuration;
using Infrastructure.Verticals.ContentBlocker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using QBittorrent.Client;
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
public sealed class QBitService : IDownloadService
{
private readonly ILogger<QBitService> _logger;
private readonly QBitConfig _config;
private readonly QBittorrentClient _client;
private readonly FilenameEvaluator _filenameEvaluator;
public QBitService(
ILogger<QBitService> logger,
IOptions<QBitConfig> config,
FilenameEvaluator filenameEvaluator
)
{
_logger = logger;
_config = config.Value;
_client = new(_config.Url);
_filenameEvaluator = filenameEvaluator;
}
public async Task LoginAsync()
{
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
{
return;
}
await _client.LoginAsync(_config.Username, _config.Password);
}
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
{
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
.FirstOrDefault();
if (torrent is null)
{
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 no files found, torrent might be stuck in Downloading metadata
if (files?.Count is null or 0)
{
return false;
}
// if all files are marked as skip
return files.All(x => x.Priority is TorrentContentPriority.Skip);
}
public 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 void Dispose()
{
_client.Dispose();
}
}

View File

@@ -0,0 +1,142 @@
using Common.Configuration;
using Infrastructure.Verticals.ContentBlocker;
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 : IDownloadService
{
private readonly ILogger<TransmissionService> _logger;
private readonly TransmissionConfig _config;
private readonly Client _client;
private readonly FilenameEvaluator _filenameEvaluator;
private TorrentInfo[]? _torrentsCache;
public TransmissionService(
ILogger<TransmissionService> logger,
IOptions<TransmissionConfig> config,
FilenameEvaluator filenameEvaluator
)
{
_logger = logger;
_config = config.Value;
_client = new(
new Uri(_config.Url, "/transmission/rpc").ToString(),
login: _config.Username,
password: _config.Password
);
_filenameEvaluator = filenameEvaluator;
}
public async Task LoginAsync()
{
await _client.GetSessionInformationAsync();
}
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
{
TorrentInfo? torrent = await GetTorrentAsync(hash);
// if no files found, torrent might be stuck in Downloading metadata
if (torrent?.FileStats?.Length is null or 0)
{
return false;
}
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
{
if (!stats.Wanted.HasValue)
{
// if any files stats are missing, do not remove
return false;
}
if (stats.Wanted.HasValue && stats.Wanted.Value)
{
// if any files are wanted, do not remove
return false;
}
}
// remove if all files are unwanted
return true;
}
public 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 void Dispose()
{
}
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];
// 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;
}
}

View File

@@ -0,0 +1,90 @@
using Common.Configuration;
using Domain.Arr.Queue;
using Domain.Enums;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.DownloadClient;
using Microsoft.Extensions.Logging;
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);
protected 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 int GetRecordId(InstanceType type, QueueRecord record) =>
type switch
{
// TODO add episode id
InstanceType.Sonarr => record.SeriesId,
InstanceType.Radarr => record.MovieId,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View 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);
}
}
}

View File

@@ -0,0 +1,60 @@
using Common.Configuration;
using Domain.Arr.Queue;
using Domain.Enums;
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<int> itemsToBeRefreshed = [];
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;
}
if (!await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
{
_logger.LogInformation("skip | {title}", record.Title);
continue;
}
itemsToBeRefreshed.Add(GetRecordId(instanceType, record));
await arrClient.DeleteQueueItemAsync(instance, record);
}
});
await arrClient.RefreshItemsAsync(instance, itemsToBeRefreshed);
}
}

View File

@@ -0,0 +1,2 @@
.*sample.*
*.zipx

View File

@@ -0,0 +1 @@
*.mkv

View File

Binary file not shown.

View File

@@ -0,0 +1 @@
localclient:da4d4b43be734d48c1bb8b9ab0e39894520994e3:10

View 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": []
}

View 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
}

View File

@@ -0,0 +1,14 @@
{
"file": 3,
"format": 1
}{
"hosts": [
[
"b5408e9794dd432789c55d8c46d15275",
"127.0.0.1",
58846,
"localclient",
"da4d4b43be734d48c1bb8b9ab0e39894520994e3"
]
]
}

View File

@@ -0,0 +1,51 @@
{
"file": 1,
"format": 1
}{
"labels": {
"radarr": {
"apply_max": false,
"apply_move_completed": false,
"apply_queue": false,
"auto_add": false,
"auto_add_trackers": [],
"is_auto_managed": false,
"max_connections": -1,
"max_download_speed": -1,
"max_upload_slots": -1,
"max_upload_speed": -1,
"move_completed": false,
"move_completed_path": "",
"prioritize_first_last": false,
"remove_at_ratio": false,
"stop_at_ratio": false,
"stop_ratio": 2.0
},
"tv-sonarr": {
"apply_max": false,
"apply_move_completed": false,
"apply_queue": false,
"auto_add": false,
"auto_add_trackers": [],
"is_auto_managed": false,
"max_connections": -1,
"max_download_speed": -1,
"max_upload_slots": -1,
"max_upload_speed": -1,
"move_completed": false,
"move_completed_path": "",
"prioritize_first_last": false,
"remove_at_ratio": false,
"stop_at_ratio": false,
"stop_ratio": 2.0
}
},
"torrent_labels": {
"2b2ec156461d77bc48b8fe4d62cede50dcdff8e0": "radarr",
"59ab2bc053430fe53e06a93e2eadb7acb6a6bf2c": "tv-sonarr",
"5a31d5f1689f5f45fd85c275a37acd2c7b82fde1": "tv-sonarr",
"6c890ff85b5317d5df291c3c23a782774e10e6fe": "radarr",
"a4a1d1dd1db25763caa8f5e4d25ad72ef304094b": "radarr",
"b72541215214be2a1d96ef6b29ca1305f5e5e1f6": "tv-sonarr"
}
}

View File

@@ -0,0 +1,429 @@
/**
* blocklist.js
*
* Copyright (C) Omar Alvarez 2014 <omar.alvarez@udc.es>
*
* This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
* the additional special exception to link portions of this program with the OpenSSL library.
* See LICENSE for more details.
*
*/
Ext.ns('Deluge.ux.preferences');
/**
* @class Deluge.ux.preferences.BlocklistPage
* @extends Ext.Panel
*/
Deluge.ux.preferences.BlocklistPage = Ext.extend(Ext.Panel, {
title: _('Blocklist'),
header: false,
layout: 'fit',
border: false,
autoScroll: true,
initComponent: function () {
Deluge.ux.preferences.BlocklistPage.superclass.initComponent.call(this);
this.URLFset = this.add({
xtype: 'fieldset',
border: false,
title: _('General'),
autoHeight: true,
defaultType: 'textfield',
style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;',
autoWidth: true,
labelWidth: 40,
});
this.URL = this.URLFset.add({
fieldLabel: _('URL:'),
labelSeparator: '',
name: 'url',
width: '80%',
});
this.SettingsFset = this.add({
xtype: 'fieldset',
border: false,
title: _('Settings'),
autoHeight: true,
defaultType: 'spinnerfield',
style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;',
autoWidth: true,
labelWidth: 160,
});
this.checkListDays = this.SettingsFset.add({
fieldLabel: _('Check for new list every (days):'),
labelSeparator: '',
name: 'check_list_days',
value: 4,
decimalPrecision: 0,
width: 80,
});
this.chkImportOnStart = this.SettingsFset.add({
xtype: 'checkbox',
fieldLabel: _('Import blocklist on startup'),
name: 'check_import_startup',
});
this.OptionsFset = this.add({
xtype: 'fieldset',
border: false,
title: _('Options'),
autoHeight: true,
defaultType: 'button',
style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;',
autoWidth: false,
width: '80%',
labelWidth: 0,
});
this.checkDownload = this.OptionsFset.add({
fieldLabel: _(''),
name: 'check_download',
xtype: 'container',
layout: 'hbox',
margins: '4 0 0 5',
items: [
{
xtype: 'button',
text: ' Check Download and Import ',
scale: 'medium',
},
{
xtype: 'box',
autoEl: {
tag: 'img',
src: '../icons/ok.png',
},
margins: '4 0 0 3',
},
],
});
this.forceDownload = this.OptionsFset.add({
fieldLabel: _(''),
name: 'force_download',
text: ' Force Download and Import ',
margins: '2 0 0 0',
//icon: '../icons/blocklist_import24.png',
scale: 'medium',
});
this.ProgressFset = this.add({
xtype: 'fieldset',
border: false,
title: _('Info'),
autoHeight: true,
defaultType: 'progress',
style: 'margin-top: 1px; margin-bottom: 0px; padding-bottom: 0px;',
autoWidth: true,
labelWidth: 0,
hidden: true,
});
this.downProgBar = this.ProgressFset.add({
fieldLabel: _(''),
name: 'progress_bar',
width: '90%',
});
this.InfoFset = this.add({
xtype: 'fieldset',
border: false,
title: _('Info'),
autoHeight: true,
defaultType: 'label',
style: 'margin-top: 0px; margin-bottom: 0px; padding-bottom: 0px;',
labelWidth: 60,
});
this.lblFileSize = this.InfoFset.add({
fieldLabel: _('File Size:'),
labelSeparator: '',
name: 'file_size',
});
this.lblDate = this.InfoFset.add({
fieldLabel: _('Date:'),
labelSeparator: '',
name: 'date',
});
this.lblType = this.InfoFset.add({
fieldLabel: _('Type:'),
labelSeparator: '',
name: 'type',
});
this.lblURL = this.InfoFset.add({
fieldLabel: _('URL:'),
labelSeparator: '',
name: 'lbl_URL',
});
this.WhitelistFset = this.add({
xtype: 'fieldset',
border: false,
title: _('Whitelist'),
autoHeight: true,
defaultType: 'editorgrid',
style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;',
autoWidth: true,
labelWidth: 0,
items: [
{
fieldLabel: _(''),
name: 'whitelist',
margins: '2 0 5 5',
height: 100,
width: 260,
autoExpandColumn: 'ip',
viewConfig: {
emptyText: _('Add an IP...'),
deferEmptyText: false,
},
colModel: new Ext.grid.ColumnModel({
columns: [
{
id: 'ip',
header: _('IP'),
dataIndex: 'ip',
sortable: true,
hideable: false,
editable: true,
editor: {
xtype: 'textfield',
},
},
],
}),
selModel: new Ext.grid.RowSelectionModel({
singleSelect: false,
moveEditorOnEnter: false,
}),
store: new Ext.data.ArrayStore({
autoDestroy: true,
fields: [{ name: 'ip' }],
}),
listeners: {
afteredit: function (e) {
e.record.commit();
},
},
setEmptyText: function (text) {
if (this.viewReady) {
this.getView().emptyText = text;
this.getView().refresh();
} else {
Ext.apply(this.viewConfig, { emptyText: text });
}
},
loadData: function (data) {
this.getStore().loadData(data);
if (this.viewReady) {
this.getView().updateHeaders();
}
},
},
],
});
this.ipButtonsContainer = this.WhitelistFset.add({
xtype: 'container',
layout: 'hbox',
margins: '4 0 0 5',
items: [
{
xtype: 'button',
text: ' Add IP ',
margins: '0 5 0 0',
},
{
xtype: 'button',
text: ' Delete IP ',
},
],
});
this.updateTask = Ext.TaskMgr.start({
interval: 2000,
run: this.onUpdate,
scope: this,
});
this.on('show', this.updateConfig, this);
this.ipButtonsContainer.getComponent(0).setHandler(this.addIP, this);
this.ipButtonsContainer.getComponent(1).setHandler(this.deleteIP, this);
this.checkDownload.getComponent(0).setHandler(this.checkDown, this);
this.forceDownload.setHandler(this.forceDown, this);
},
onApply: function () {
var config = {};
config['url'] = this.URL.getValue();
config['check_after_days'] = this.checkListDays.getValue();
config['load_on_start'] = this.chkImportOnStart.getValue();
var ipList = [];
var store = this.WhitelistFset.getComponent(0).getStore();
for (var i = 0; i < store.getCount(); i++) {
var record = store.getAt(i);
var ip = record.get('ip');
ipList.push(ip);
}
config['whitelisted'] = ipList;
deluge.client.blocklist.set_config(config);
},
onOk: function () {
this.onApply();
},
onUpdate: function () {
deluge.client.blocklist.get_status({
success: function (status) {
if (status['state'] == 'Downloading') {
this.InfoFset.hide();
this.checkDownload.getComponent(0).setDisabled(true);
this.checkDownload.getComponent(1).hide();
this.forceDownload.setDisabled(true);
this.ProgressFset.show();
this.downProgBar.updateProgress(
status['file_progress'],
'Downloading '
.concat((status['file_progress'] * 100).toFixed(2))
.concat('%'),
true
);
} else if (status['state'] == 'Importing') {
this.InfoFset.hide();
this.checkDownload.getComponent(0).setDisabled(true);
this.checkDownload.getComponent(1).hide();
this.forceDownload.setDisabled(true);
this.ProgressFset.show();
this.downProgBar.updateText(
'Importing '.concat(status['num_blocked'])
);
} else if (status['state'] == 'Idle') {
this.ProgressFset.hide();
this.checkDownload.getComponent(0).setDisabled(false);
this.forceDownload.setDisabled(false);
if (status['up_to_date']) {
this.checkDownload.getComponent(1).show();
this.checkDownload.doLayout();
} else {
this.checkDownload.getComponent(1).hide();
}
this.InfoFset.show();
this.lblFileSize.setText(fsize(status['file_size']));
this.lblDate.setText(fdate(status['file_date']));
this.lblType.setText(status['file_type']);
this.lblURL.setText(
status['file_url'].substr(0, 40).concat('...')
);
}
},
scope: this,
});
},
checkDown: function () {
this.onApply();
deluge.client.blocklist.check_import();
},
forceDown: function () {
this.onApply();
deluge.client.blocklist.check_import((force = true));
},
updateConfig: function () {
deluge.client.blocklist.get_config({
success: function (config) {
this.URL.setValue(config['url']);
this.checkListDays.setValue(config['check_after_days']);
this.chkImportOnStart.setValue(config['load_on_start']);
var data = [];
var keys = Ext.keys(config['whitelisted']);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
data.push([config['whitelisted'][key]]);
}
this.WhitelistFset.getComponent(0).loadData(data);
},
scope: this,
});
deluge.client.blocklist.get_status({
success: function (status) {
this.lblFileSize.setText(fsize(status['file_size']));
this.lblDate.setText(fdate(status['file_date']));
this.lblType.setText(status['file_type']);
this.lblURL.setText(
status['file_url'].substr(0, 40).concat('...')
);
},
scope: this,
});
},
addIP: function () {
var store = this.WhitelistFset.getComponent(0).getStore();
var IP = store.recordType;
var i = new IP({
ip: '',
});
this.WhitelistFset.getComponent(0).stopEditing();
store.insert(0, i);
this.WhitelistFset.getComponent(0).startEditing(0, 0);
},
deleteIP: function () {
var selections = this.WhitelistFset.getComponent(0)
.getSelectionModel()
.getSelections();
var store = this.WhitelistFset.getComponent(0).getStore();
this.WhitelistFset.getComponent(0).stopEditing();
for (var i = 0; i < selections.length; i++) store.remove(selections[i]);
store.commitChanges();
},
onDestroy: function () {
Ext.TaskMgr.stop(this.updateTask);
deluge.preferences.un('show', this.updateConfig, this);
Deluge.ux.preferences.BlocklistPage.superclass.onDestroy.call(this);
},
});
Deluge.plugins.BlocklistPlugin = Ext.extend(Deluge.Plugin, {
name: 'Blocklist',
onDisable: function () {
deluge.preferences.removePage(this.prefsPage);
},
onEnable: function () {
this.prefsPage = deluge.preferences.addPage(
new Deluge.ux.preferences.BlocklistPage()
);
},
});
Deluge.registerPlugin('Blocklist', Deluge.plugins.BlocklistPlugin);

View File

@@ -0,0 +1,635 @@
/**
* label.js
*
* Copyright (C) Damien Churchill 2010 <damoxc@gmail.com>
*
* This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
* the additional special exception to link portions of this program with the OpenSSL library.
* See LICENSE for more details.
*
*/
Ext.ns('Deluge.ux.preferences');
/**
* @class Deluge.ux.preferences.LabelPage
* @extends Ext.Panel
*/
Deluge.ux.preferences.LabelPage = Ext.extend(Ext.Panel, {
title: _('Label'),
layout: 'fit',
border: false,
initComponent: function () {
Deluge.ux.preferences.LabelPage.superclass.initComponent.call(this);
fieldset = this.add({
xtype: 'fieldset',
border: false,
title: _('Label Preferences'),
autoHeight: true,
labelWidth: 1,
defaultType: 'panel',
});
fieldset.add({
border: false,
bodyCfg: {
html: _(
'<p>The Label plugin is enabled.</p><br>' +
'<p>To add, remove or edit labels right-click on the Label filter ' +
'entry in the sidebar.</p><br>' +
'<p>To apply a label right-click on torrent(s).<p>'
),
},
});
},
});
Ext.ns('Deluge.ux');
/**
* @class Deluge.ux.AddLabelWindow
* @extends Ext.Window
*/
Deluge.ux.AddLabelWindow = Ext.extend(Ext.Window, {
title: _('Add Label'),
width: 300,
height: 100,
closeAction: 'hide',
initComponent: function () {
Deluge.ux.AddLabelWindow.superclass.initComponent.call(this);
this.addButton(_('Cancel'), this.onCancelClick, this);
this.addButton(_('Ok'), this.onOkClick, this);
this.form = this.add({
xtype: 'form',
height: 35,
baseCls: 'x-plain',
bodyStyle: 'padding:5px 5px 0',
defaultType: 'textfield',
labelWidth: 50,
items: [
{
fieldLabel: _('Name'),
name: 'name',
allowBlank: false,
width: 220,
listeners: {
specialkey: {
fn: function (field, e) {
if (e.getKey() == 13) this.onOkClick();
},
scope: this,
},
},
},
],
});
},
onCancelClick: function () {
this.hide();
},
onOkClick: function () {
var label = this.form.getForm().getValues().name;
deluge.client.label.add(label, {
success: function () {
deluge.ui.update();
this.fireEvent('labeladded', label);
},
scope: this,
});
this.hide();
},
onHide: function (comp) {
Deluge.ux.AddLabelWindow.superclass.onHide.call(this, comp);
this.form.getForm().reset();
},
onShow: function (comp) {
Deluge.ux.AddLabelWindow.superclass.onShow.call(this, comp);
this.form.getForm().findField('name').focus(false, 150);
},
});
/**
* @class Deluge.ux.LabelOptionsWindow
* @extends Ext.Window
*/
Deluge.ux.LabelOptionsWindow = Ext.extend(Ext.Window, {
title: _('Label Options'),
width: 325,
height: 240,
closeAction: 'hide',
initComponent: function () {
Deluge.ux.LabelOptionsWindow.superclass.initComponent.call(this);
this.addButton(_('Cancel'), this.onCancelClick, this);
this.addButton(_('Ok'), this.onOkClick, this);
this.form = this.add({
xtype: 'form',
});
this.tabs = this.form.add({
xtype: 'tabpanel',
height: 175,
border: false,
items: [
{
title: _('Maximum'),
items: [
{
border: false,
items: [
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
style: 'margin-bottom: 0px; padding-bottom: 0px;',
items: [
{
xtype: 'checkbox',
name: 'apply_max',
fieldLabel: '',
boxLabel: _(
'Apply per torrent max settings:'
),
listeners: {
check: this.onFieldChecked,
},
},
],
},
{
xtype: 'fieldset',
border: false,
defaultType: 'spinnerfield',
style: 'margin-top: 0px; padding-top: 0px;',
items: [
{
fieldLabel: _('Download Speed'),
name: 'max_download_speed',
width: 80,
disabled: true,
value: -1,
minValue: -1,
},
{
fieldLabel: _('Upload Speed'),
name: 'max_upload_speed',
width: 80,
disabled: true,
value: -1,
minValue: -1,
},
{
fieldLabel: _('Upload Slots'),
name: 'max_upload_slots',
width: 80,
disabled: true,
value: -1,
minValue: -1,
},
{
fieldLabel: _('Connections'),
name: 'max_connections',
width: 80,
disabled: true,
value: -1,
minValue: -1,
},
],
},
],
},
],
},
{
title: _('Queue'),
items: [
{
border: false,
items: [
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
style: 'margin-bottom: 0px; padding-bottom: 0px;',
items: [
{
xtype: 'checkbox',
name: 'apply_queue',
fieldLabel: '',
boxLabel: _(
'Apply queue settings:'
),
listeners: {
check: this.onFieldChecked,
},
},
],
},
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
defaultType: 'checkbox',
style: 'margin-top: 0px; padding-top: 0px;',
defaults: {
style: 'margin-left: 20px',
},
items: [
{
boxLabel: _('Auto Managed'),
name: 'is_auto_managed',
disabled: true,
},
{
boxLabel: _('Stop seed at ratio:'),
name: 'stop_at_ratio',
disabled: true,
},
{
xtype: 'spinnerfield',
name: 'stop_ratio',
width: 60,
decimalPrecision: 2,
incrementValue: 0.1,
style: 'position: relative; left: 100px',
disabled: true,
},
{
boxLabel: _('Remove at ratio'),
name: 'remove_at_ratio',
disabled: true,
},
],
},
],
},
],
},
{
title: _('Folders'),
items: [
{
border: false,
items: [
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
style: 'margin-bottom: 0px; padding-bottom: 0px;',
items: [
{
xtype: 'checkbox',
name: 'apply_move_completed',
fieldLabel: '',
boxLabel: _(
'Apply folder settings:'
),
listeners: {
check: this.onFieldChecked,
},
},
],
},
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
defaultType: 'checkbox',
labelWidth: 1,
style: 'margin-top: 0px; padding-top: 0px;',
defaults: {
style: 'margin-left: 20px',
},
items: [
{
boxLabel: _('Move completed to:'),
name: 'move_completed',
disabled: true,
},
{
xtype: 'textfield',
name: 'move_completed_path',
width: 250,
disabled: true,
},
],
},
],
},
],
},
{
title: _('Trackers'),
items: [
{
border: false,
items: [
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
style: 'margin-bottom: 0px; padding-bottom: 0px;',
items: [
{
xtype: 'checkbox',
name: 'auto_add',
fieldLabel: '',
boxLabel: _(
'Automatically apply label:'
),
listeners: {
check: this.onFieldChecked,
},
},
],
},
{
xtype: 'fieldset',
border: false,
labelWidth: 1,
style: 'margin-top: 0px; padding-top: 0px;',
defaults: {
style: 'margin-left: 20px',
},
defaultType: 'textarea',
items: [
{
boxLabel: _('Move completed to:'),
name: 'auto_add_trackers',
width: 250,
height: 100,
disabled: true,
},
],
},
],
},
],
},
],
});
},
getLabelOptions: function () {
deluge.client.label.get_options(this.label, {
success: this.gotOptions,
scope: this,
});
},
gotOptions: function (options) {
this.form.getForm().setValues(options);
},
show: function (label) {
Deluge.ux.LabelOptionsWindow.superclass.show.call(this);
this.label = label;
this.setTitle(_('Label Options') + ': ' + this.label);
this.tabs.setActiveTab(0);
this.getLabelOptions();
},
onCancelClick: function () {
this.hide();
},
onOkClick: function () {
var values = this.form.getForm().getFieldValues();
if (values['auto_add_trackers']) {
values['auto_add_trackers'] =
values['auto_add_trackers'].split('\n');
}
deluge.client.label.set_options(this.label, values);
this.hide();
},
onFieldChecked: function (field, checked) {
var fs = field.ownerCt.nextSibling();
fs.items.each(function (field) {
field.setDisabled(!checked);
});
},
});
Ext.ns('Deluge.plugins');
/**
* @class Deluge.plugins.LabelPlugin
* @extends Deluge.Plugin
*/
Deluge.plugins.LabelPlugin = Ext.extend(Deluge.Plugin, {
name: 'Label',
createMenu: function () {
this.labelMenu = new Ext.menu.Menu({
items: [
{
text: _('Add Label'),
iconCls: 'icon-add',
handler: this.onLabelAddClick,
scope: this,
},
{
text: _('Remove Label'),
disabled: true,
iconCls: 'icon-remove',
handler: this.onLabelRemoveClick,
scope: this,
},
{
text: _('Label Options'),
disabled: true,
handler: this.onLabelOptionsClick,
scope: this,
},
],
});
},
setFilter: function (filter) {
filter.show_zero = true;
filter.list.on('contextmenu', this.onLabelContextMenu, this);
filter.header.on('contextmenu', this.onLabelHeaderContextMenu, this);
this.filter = filter;
},
updateTorrentMenu: function (states) {
this.torrentMenu.removeAll(true);
this.torrentMenu.addMenuItem({
text: _('No Label'),
label: '',
handler: this.onTorrentMenuClick,
scope: this,
});
for (var state in states) {
if (!state || state == 'All') continue;
this.torrentMenu.addMenuItem({
text: state,
label: state,
handler: this.onTorrentMenuClick,
scope: this,
});
}
},
onDisable: function () {
deluge.sidebar.un('filtercreate', this.onFilterCreate);
deluge.sidebar.un('afterfiltercreate', this.onAfterFilterCreate);
delete Deluge.FilterPanel.templates.label;
this.deregisterTorrentStatus('label');
deluge.menus.torrent.remove(this.tmSep);
deluge.menus.torrent.remove(this.tm);
deluge.preferences.removePage(this.prefsPage);
},
onEnable: function () {
this.prefsPage = deluge.preferences.addPage(
new Deluge.ux.preferences.LabelPage()
);
this.torrentMenu = new Ext.menu.Menu();
this.tmSep = deluge.menus.torrent.add({
xtype: 'menuseparator',
});
this.tm = deluge.menus.torrent.add({
text: _('Label'),
menu: this.torrentMenu,
});
var lbltpl =
'<div class="x-deluge-filter">' +
'<tpl if="filter">{filter}</tpl>' +
'<tpl if="!filter">No Label</tpl>' +
' ({count})' +
'</div>';
if (deluge.sidebar.hasFilter('label')) {
var filter = deluge.sidebar.getFilter('label');
filter.list.columns[0].tpl = new Ext.XTemplate(lbltpl);
this.setFilter(filter);
this.updateTorrentMenu(filter.getStates());
filter.list.refresh();
} else {
deluge.sidebar.on('filtercreate', this.onFilterCreate, this);
deluge.sidebar.on(
'afterfiltercreate',
this.onAfterFilterCreate,
this
);
Deluge.FilterPanel.templates.label = lbltpl;
}
this.registerTorrentStatus('label', _('Label'));
},
onAfterFilterCreate: function (sidebar, filter) {
if (filter.filter != 'label') return;
this.updateTorrentMenu(filter.getStates());
},
onFilterCreate: function (sidebar, filter) {
if (filter.filter != 'label') return;
this.setFilter(filter);
},
onLabelAddClick: function () {
if (!this.addWindow) {
this.addWindow = new Deluge.ux.AddLabelWindow();
this.addWindow.on('labeladded', this.onLabelAdded, this);
}
this.addWindow.show();
},
onLabelAdded: function (label) {
var filter = deluge.sidebar.getFilter('label');
var states = filter.getStates();
var statesArray = [];
for (state in states) {
if (!state || state == 'All') continue;
statesArray.push(state);
}
statesArray.push(label.toLowerCase());
statesArray.sort();
//console.log(states);
//console.log(statesArray);
states = {};
for (i = 0; i < statesArray.length; ++i) {
states[statesArray[i]] = 0;
}
this.updateTorrentMenu(states);
},
onLabelContextMenu: function (dv, i, node, e) {
e.preventDefault();
if (!this.labelMenu) this.createMenu();
var r = dv.getRecord(node).get('filter');
if (!r || r == 'All') {
this.labelMenu.items.get(1).setDisabled(true);
this.labelMenu.items.get(2).setDisabled(true);
} else {
this.labelMenu.items.get(1).setDisabled(false);
this.labelMenu.items.get(2).setDisabled(false);
}
dv.select(i);
this.labelMenu.showAt(e.getXY());
},
onLabelHeaderContextMenu: function (e, t) {
e.preventDefault();
if (!this.labelMenu) this.createMenu();
this.labelMenu.items.get(1).setDisabled(true);
this.labelMenu.items.get(2).setDisabled(true);
this.labelMenu.showAt(e.getXY());
},
onLabelOptionsClick: function () {
if (!this.labelOpts)
this.labelOpts = new Deluge.ux.LabelOptionsWindow();
this.labelOpts.show(this.filter.getState());
},
onLabelRemoveClick: function () {
var state = this.filter.getState();
deluge.client.label.remove(state, {
success: function () {
deluge.ui.update();
this.torrentMenu.items.each(function (item) {
if (item.text != state) return;
this.torrentMenu.remove(item);
var i = item;
}, this);
},
scope: this,
});
},
onTorrentMenuClick: function (item, e) {
var ids = deluge.torrents.getSelectedIds();
Ext.each(ids, function (id, i) {
if (ids.length == i + 1) {
deluge.client.label.set_torrent(id, item.label, {
success: function () {
deluge.ui.update();
},
});
} else {
deluge.client.label.set_torrent(id, item.label);
}
});
},
});
Deluge.registerPlugin('Label', Deluge.plugins.LabelPlugin);

View File

Binary file not shown.

View File

@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICpDCCAYwCAQAwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAwwNRGVsdWdlIERh
ZW1vbjAeFw0yNDExMTQxMjI0MjVaFw0yNzExMTQxMjI0MjVaMBgxFjAUBgNVBAMM
DURlbHVnZSBEYWVtb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDk
hV3jiOW40yERiQ6F0GgsM8doBRaCzdJ5du7JRKY0bxNzpkgsmjJp8HFljeROPbOl
plyWzJuol02sQ5WlcnUppPZCFlm3Hnw4wdsM2F7n4MC3i2M8/M73pIBbXw/7Ekro
ZijmS02DT3o6c4Urdh89w3GRs6MWESikBdzTDAVPV8REASAfoI1JVUFznxqEMysx
H9ANqdlkO0sMnBvOFvNxAyuVMOwCUEFsw7ynutJB/yrMUk1itoX21CigOH+pkNDe
JnfIKRa6BvU4aLCFGynAR3bk7TcwRiPoIiWPmwxktFVc+sr26fuGWd8KSPjOJZGV
+WZjYAqtiZRFX67VgAf1AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAC3vWaRkzIue
9onnmcpv0uXPNDANefAL4B9sPVmWVdBrWGJTGm9umWZGOa2Z1VRGaC8+LpHJ074f
gmv/PE61GxL5usmfRRLJK4ZKIPzoIspqVKfuWJH6kbsAzg3x43RF0WvokJQKC+Y/
tWY8a6ewJ1Uh1YDJEwxgR+WBguN64w4QdujPGoDnAWAEW7VJsc2PlYzYConpwKXy
RUOpcnZnAV3z98zRU6m0G/RyYZF8H00hXsEitDeuh5Kdu1tLCbhUYvVXxB6BY559
bJ+DrxblqpM71wamFq9MDv+Z78XgGZFymoLLLRV3gBO1RsKVnl81Ywvo2LMkRp52
XuvUJJPD6po=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDkhV3jiOW40yER
iQ6F0GgsM8doBRaCzdJ5du7JRKY0bxNzpkgsmjJp8HFljeROPbOlplyWzJuol02s
Q5WlcnUppPZCFlm3Hnw4wdsM2F7n4MC3i2M8/M73pIBbXw/7EkroZijmS02DT3o6
c4Urdh89w3GRs6MWESikBdzTDAVPV8REASAfoI1JVUFznxqEMysxH9ANqdlkO0sM
nBvOFvNxAyuVMOwCUEFsw7ynutJB/yrMUk1itoX21CigOH+pkNDeJnfIKRa6BvU4
aLCFGynAR3bk7TcwRiPoIiWPmwxktFVc+sr26fuGWd8KSPjOJZGV+WZjYAqtiZRF
X67VgAf1AgMBAAECggEAA4T6ToghNu4pfYz60vIZaJ+I2//tZNOpVi2QEpF4GH6i
x2PcNgj56x/E31Ixc0hdUpkeppk9cc9CvFBzfDooYR0lSHHyV8ZPFiCw2vXKIGVv
EmSXLAKeErpPL8O7CfGHgyTE+dGsaUVOwJpe3AMptVh5O0vk9cYLNjDQ7H8sOxiQ
0uCTu7JyKRVOtmp9EFy/KqnHPaGVFuNmQH5byiDhuHWFC3lbC2QeYrlhMnPv38jw
NVuUI10E+ZlJJuhuSOwTdKTj0XxhtvMclsbkXOCGm4nL13EYqcyrTiHFbxDCV5c3
V33xmtH+ABvdvF/68Ouk6ph1BRLTpAW/UmURcUvpUQKBgQD3O2clEmBac3yA487T
/uBhqId5JU5vAYltH2Cfs46aaDUjHe4QshuMPUiyyiZgFh2oc2JXbmDgbXkyrkyq
2K5sOcixKef/eoAIkT+o2Nd8PDMpApF0AcqyWAe0xNR0thWJvjdMon4aFHHZoCcW
+zyWYB8cxVdZCcPnnykpvJgSdwKBgQDsoBZxrq9Ta3S0Ho7NEYu4b6sh9rCfXCU7
94+eeWPc6+oO+jQDtIS+RYWKwOKmqMzhzq1MdTl9+2yGTCa1st05gsyUpEpso2r5
BlHUVBzrDEMhRN5FqOcKuRZ+G2HMAsTxJbVZnOS9Z0mf5nwCvJxs8jUEPbFDWQVr
AcKRApTH8wKBgQDw3T3TH0EiPks5Izh4z2L5ogBCZbcxbNTfrGctj/jJs+a5DMrI
F03BZl9yWIHksQc5+xf/SDk3zU/7sVZeSHY+WFmPSN2OyGD+d8wGiyP9FIVfWfIt
jCVXdW4kjnLSNidrqBcmIVUrwWld9aq/uAtCEemd1SERTPNAsI6g6+1YZwKBgQCL
P55Voi4NElRoVv9EUMn/bL+xygGgllJXGtWKtfb9oFtqGvWXJJlle3Yd9GqtFvMT
A1RahTWjHN19nry8+phTatTHuHMPwY+HIp/vKtylud6banK/XakxV0CUT7ramtqY
6s7xAHJfv7PFBJb/6UzIlDR83W0+q9mTYkLEoVc63wKBgCV2ok/C7+7e189gxUir
3ezxBlY9Cv+1/wIE5IjAwpmOPPzsZMKZ2SbDxJMoDBXACJtgOEleiUSYss9qkhVt
o0kUkEOWPM3vydRLCLcsdfpM826WAmGA/w6MsqWyZDoc/kw7UhEBqMhQVXzc/cGF
gRXRDfyZj4MpvAOBPTlTWbme
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,2 @@
d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731591387e4:infod6:lengthi6e4:name97:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG.mkv.zipx12:piece lengthi262144e6:pieces20:<3A><>s<EFBFBD><73><EFBFBD><EFBFBD>^<5E>^o
z<EFBFBD><17><1F><><EFBFBD>ee

View File

Binary file not shown.

View File

@@ -0,0 +1,25 @@
{
"file": 2,
"format": 1
}{
"base": "/",
"cert": "ssl/daemon.cert",
"default_daemon": "",
"enabled_plugins": [],
"first_login": false,
"https": false,
"interface": "0.0.0.0",
"language": "",
"pkey": "ssl/daemon.pkey",
"port": 8112,
"pwd_salt": "2bc0ed67acc6876dda1a1632594090478fdeab60",
"pwd_sha1": "3ac8756d294abe4f6c9dfa084b7fc2c84ce32f68",
"session_timeout": 3600,
"sessions": {
},
"show_session_speed": false,
"show_sidebar": true,
"sidebar_multiple_filters": true,
"sidebar_show_zero": false,
"theme": "gray"
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="73140dfd-12c2-49d9-93d6-94dd1f0bc538" version="1">
<creationDate>2024-11-12T08:27:40.5991235Z</creationDate>
<activationDate>2024-11-12T08:27:40.5870855Z</activationDate>
<expirationDate>2025-02-10T08:27:40.5870855Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>FJN9+ak89dkr+ZPZD/LymeCCwH/UI3kNdaMqxSnY6G8bui1yNjGtLpQQOJJlTOAdAyZvHUyPUvv99F70uZF7qg==</value>
</masterKey>
</descriptor>
</descriptor>
</key>

View File

Binary file not shown.

View File

@@ -0,0 +1 @@
145

View File

Binary file not shown.

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