mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-02 10:57:52 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6f62dd70 | ||
|
|
77cc5c99ed | ||
|
|
513134fd65 | ||
|
|
906be45758 | ||
|
|
c4a15e77e4 | ||
|
|
baffdfdd5a | ||
|
|
827afb5a4d | ||
|
|
26939b2cd3 | ||
|
|
34d05c5416 | ||
|
|
b9376e02fa | ||
|
|
2d5c59ddba | ||
|
|
11864617de | ||
|
|
d5bff76a62 |
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@@ -8,4 +8,5 @@ jobs:
|
||||
with:
|
||||
dockerRepository: flaminel/cleanuperr
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
outputName: cleanuperr
|
||||
secrets: inherit
|
||||
7
.github/workflows/pipeline.yml
vendored
7
.github/workflows/pipeline.yml
vendored
@@ -1,8 +1,13 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
# paths:
|
||||
# - 'code/**'
|
||||
# branches: [ main ]
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
83
.github/workflows/release.yml
vendored
83
.github/workflows/release.yml
vendored
@@ -1,78 +1,11 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version number'
|
||||
required: true
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore code/Executable/Executable.csproj
|
||||
|
||||
- name: Build win-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime win-x64 --self-contained -o artifacts/cleanuperr-win /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build linux-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/cleanuperr-linux /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build osx-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/cleanuperr-osx /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Zip the Win release
|
||||
run: zip ./artifacts/cleanuperr-win.zip ./artifacts/cleanuperr-win/cleanuperr.exe ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Zip the Linux release
|
||||
run: zip ./artifacts/cleanuperr-linux.zip ./artifacts/cleanuperr-linux/cleanuperr ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Zip the OSX release
|
||||
run: zip ./artifacts/cleanuperr-osx.zip ./artifacts/cleanuperr-osx/cleanuperr ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.1.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
release_name: Release ${{ github.event.inputs.version }}
|
||||
draft: false
|
||||
|
||||
- name: Upload cleanuperr-win
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-win.zip
|
||||
asset_name: cleanuperr-win.zip
|
||||
asset_content_type: application/exe
|
||||
|
||||
- name: Upload cleanuperr-linux
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-linux.zip
|
||||
asset_name: cleanuperr-linux.zip
|
||||
asset_content_type: application/exe
|
||||
|
||||
- name: Upload cleanuperr-osx
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-osx.zip
|
||||
asset_name: cleanuperr-osx.zip
|
||||
asset_content_type: application/exe
|
||||
release:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.release.yml@main
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
secrets: inherit
|
||||
640
README.md
640
README.md
@@ -1,143 +1,591 @@
|
||||
# cleanuperr
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
docker run \
|
||||
-e QuartzConfig__BlockedTorrentTrigger="0 0/10 * * * ?" \
|
||||
-e QBitConfig__Url="http://localhost:8080" \
|
||||
-e QBitConfig__Username="user" \
|
||||
-e QBitConfig__Password="pass" \
|
||||
-e SonarrConfig__Instances__0__Url="http://localhost:8989" \
|
||||
-e SonarrConfig__Instances__0__ApiKey="secret1" \
|
||||
-e SonarrConfig__Instances__1__Url="http://localhost:8990" \
|
||||
-e SonarrConfig__Instances__1__ApiKey="secret2" \
|
||||
...
|
||||
flaminel/cleanuperr:latest
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
#
|
||||
|
||||
Multiple Sonarr instances can be specified using this format:
|
||||
|
||||
```
|
||||
SonarrConfig__Instances__<NUMBER>__Url
|
||||
SonarrConfig__Instances__<NUMBER>__ApiKey
|
||||
```
|
||||
|
||||
where `<NUMBER>` starts from 0.
|
||||
|
||||
## 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.
|
||||
2. cleanuperr goes through all items in Sonarr's queue 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.
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker
|
||||
```
|
||||
docker run -d \
|
||||
-e TRIGGERS__QUEUECLEANER="0 0/5 * * * ?" \
|
||||
-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:1.1.0
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| TRIGGERS__QUEUECLEANER | No | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html) | 0 0/5 * * * ? |
|
||||
| QBITTORRENT__URL | Yes | qBittorrent instance url | http://localhost:8080 |
|
||||
| QBITTORRENT__USERNAME | Yes | qBittorrent user | empty |
|
||||
| QBITTORRENT__PASSWORD | Yes | qBittorrent password | empty |
|
||||
|
|
||||
| SONARR__ENABLED | No | Whether Sonarr cleanup is enabled or not | 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 | Whether Radarr cleanup is enabled or not | false |
|
||||
| RADARR__INSTANCES__0__URL | Yes | First Radarr instance url | http://localhost:8989 |
|
||||
| RADARR__INSTANCES__0__APIKEY | Yes | First Radarr instance API key | empty |
|
||||
|
||||
#
|
||||
|
||||
Multiple Sonarr/Radarr instances can be specified using this format:
|
||||
|
||||
```
|
||||
SONARR__INSTANCES__<NUMBER>__URL
|
||||
SONARR__INSTANCES__<NUMBER>__APIKEY
|
||||
```
|
||||
|
||||
where `<NUMBER>` starts from 0.
|
||||
|
||||
#
|
||||
|
||||
### Binaries
|
||||
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](/README.md#environment-variables).
|
||||
## Extensions to block in qBittorrent
|
||||
<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
|
||||
<summary>Extensions</summary>
|
||||
<pre><code>*(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
|
||||
*(sample).*
|
||||
*sample.mkv
|
||||
*sample.mp4
|
||||
*.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
|
||||
Trailer.*
|
||||
*.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
|
||||
*.Lnk
|
||||
*.zl9
|
||||
*.zoo
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
Trailer.*
|
||||
VOSTFR
|
||||
api
|
||||
</code></pre>
|
||||
</details>
|
||||
8
code/Common/Configuration/ArrConfig.cs
Normal file
8
code/Common/Configuration/ArrConfig.cs
Normal 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; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class SonarrInstance
|
||||
public sealed class ArrInstance
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
public sealed class QBitConfig
|
||||
{
|
||||
public const string SectionName = "qBittorrent";
|
||||
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
public required string Username { get; set; }
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class QuartzConfig
|
||||
{
|
||||
public required string BlockedTorrentTrigger { get; init; }
|
||||
}
|
||||
6
code/Common/Configuration/RadarrConfig.cs
Normal file
6
code/Common/Configuration/RadarrConfig.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed record RadarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Radarr";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
8
code/Common/Configuration/TriggersConfig.cs
Normal file
8
code/Common/Configuration/TriggersConfig.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class TriggersConfig
|
||||
{
|
||||
public const string SectionName = "Triggers";
|
||||
|
||||
public required string QueueCleaner { get; init; }
|
||||
}
|
||||
9
code/Domain/Arr/Enums/InstanceType.cs
Normal file
9
code/Domain/Arr/Enums/InstanceType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Domain.Arr.Enums;
|
||||
|
||||
public enum InstanceType
|
||||
{
|
||||
Sonarr,
|
||||
Radarr,
|
||||
Lidarr,
|
||||
Readarr
|
||||
}
|
||||
7
code/Domain/Arr/Queue/QueueListResponse.cs
Normal file
7
code/Domain/Arr/Queue/QueueListResponse.cs
Normal 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; }
|
||||
}
|
||||
14
code/Domain/Arr/Queue/QueueRecord.cs
Normal file
14
code/Domain/Arr/Queue/QueueRecord.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Domain.Arr.Queue;
|
||||
|
||||
public record QueueRecord
|
||||
{
|
||||
public int SeriesId { 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; }
|
||||
}
|
||||
8
code/Domain/Radarr/RadarrCommand.cs
Normal file
8
code/Domain/Radarr/RadarrCommand.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Radarr;
|
||||
|
||||
public sealed record RadarrCommand
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required HashSet<int> MovieIds { get; init; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record CustomFormat(
|
||||
int Id,
|
||||
string Name
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Language(
|
||||
int Id,
|
||||
string Name
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record QueueListResponse(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string SortKey,
|
||||
string SortDirection,
|
||||
int TotalRecords,
|
||||
IReadOnlyList<Record> Records
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Record(
|
||||
int SeriesId,
|
||||
int EpisodeId,
|
||||
int SeasonNumber,
|
||||
IReadOnlyList<Language> Languages,
|
||||
IReadOnlyList<CustomFormat> CustomFormats,
|
||||
int CustomFormatScore,
|
||||
int Size,
|
||||
string Title,
|
||||
int Sizeleft,
|
||||
string Timeleft,
|
||||
DateTime EstimatedCompletionTime,
|
||||
DateTime Added,
|
||||
string Status,
|
||||
string TrackedDownloadStatus,
|
||||
string TrackedDownloadState,
|
||||
IReadOnlyList<StatusMessage> StatusMessages,
|
||||
string DownloadId,
|
||||
string Protocol,
|
||||
string DownloadClient,
|
||||
bool DownloadClientHasPostImportCategory,
|
||||
string Indexer,
|
||||
string OutputPath,
|
||||
bool EpisodeHasFile,
|
||||
int Id
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Revision(
|
||||
int Version,
|
||||
int Real,
|
||||
bool IsRepack
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record StatusMessage(
|
||||
string Title,
|
||||
IReadOnlyList<string> Messages
|
||||
);
|
||||
8
code/Domain/Sonarr/SonarrCommand.cs
Normal file
8
code/Domain/Sonarr/SonarrCommand.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Domain.Sonarr;
|
||||
|
||||
public sealed record SonarrCommand
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required int SeriesId { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Common.Configuration;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.BlockedTorrent;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
namespace Executable;
|
||||
using Quartz;
|
||||
@@ -17,44 +18,46 @@ public static class DependencyInjection
|
||||
|
||||
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)));
|
||||
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
||||
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
|
||||
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName));
|
||||
|
||||
private static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddTransient<BlockedTorrentJob>()
|
||||
.AddTransient<BlockedTorrentHandler>();
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<QueueCleanerJob>()
|
||||
.AddTransient<QueueCleanerHandler>();
|
||||
|
||||
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get<QuartzConfig>();
|
||||
TriggersConfig? config = configuration.GetRequiredSection(TriggersConfig.SectionName).Get<TriggersConfig>();
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException("Quartz configuration is null");
|
||||
}
|
||||
|
||||
q.AddBlockedTorrentJob(config.BlockedTorrentTrigger);
|
||||
q.AddQueueCleanerJob(config.QueueCleaner);
|
||||
})
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
|
||||
private static void AddBlockedTorrentJob(this IServiceCollectionQuartzConfigurator q, string trigger)
|
||||
private static void AddQueueCleanerJob(this IServiceCollectionQuartzConfigurator q, string trigger)
|
||||
{
|
||||
q.AddJob<BlockedTorrentJob>(opts =>
|
||||
q.AddJob<QueueCleanerJob>(opts =>
|
||||
{
|
||||
opts.WithIdentity(nameof(BlockedTorrentJob));
|
||||
opts.WithIdentity(nameof(QueueCleanerJob));
|
||||
});
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(nameof(BlockedTorrentJob))
|
||||
.WithIdentity($"{nameof(BlockedTorrentJob)}-trigger")
|
||||
opts.ForJob(nameof(QueueCleanerJob))
|
||||
.WithIdentity($"{nameof(QueueCleanerJob)}-trigger")
|
||||
.WithCronSchedule(trigger);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>cleanuperr</AssemblyName>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
29
code/Executable/Jobs/QueueCleanerJob.cs
Normal file
29
code/Executable/Jobs/QueueCleanerJob.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Quartz;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class QueueCleanerJob : IJob
|
||||
{
|
||||
private ILogger<QueueCleanerJob> _logger;
|
||||
private QueueCleanerHandler _handler;
|
||||
|
||||
public QueueCleanerJob(ILogger<QueueCleanerJob> logger, QueueCleanerHandler handler)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _handler.HandleAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(QueueCleanerJob)} failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"QuartzConfig": {
|
||||
"BlockedTorrentTrigger": "0 0/1 * * * ?"
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0 0/1 * * * ?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,30 @@
|
||||
"System.Net.Http.HttpClient": "Error"
|
||||
}
|
||||
},
|
||||
"QuartzConfig": {
|
||||
"BlockedTorrentTrigger": "0 0/5 * * * ?"
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0 0/5 * * * ?"
|
||||
},
|
||||
"QBitConfig": {
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"SonarrConfig": {
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": false,
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
77
code/Infrastructure/Verticals/Arr/ArrClient.cs
Normal file
77
code/Infrastructure/Verticals/Arr/ArrClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
52
code/Infrastructure/Verticals/Arr/RadarrClient.cs
Normal file
52
code/Infrastructure/Verticals/Arr/RadarrClient.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
using Common.Configuration;
|
||||
using Domain.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
code/Infrastructure/Verticals/Arr/SonarrClient.cs
Normal file
50
code/Infrastructure/Verticals/Arr/SonarrClient.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Text;
|
||||
using Common.Configuration;
|
||||
using Domain.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
using System.Text;
|
||||
using Common.Configuration;
|
||||
using Domain.Sonarr.Queue;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.BlockedTorrent;
|
||||
|
||||
public sealed class BlockedTorrentHandler
|
||||
{
|
||||
private readonly ILogger<BlockedTorrentHandler> _logger;
|
||||
private readonly QBitConfig _qBitConfig;
|
||||
private readonly SonarrConfig _sonarrConfig;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string QueueListPathTemplate = "/api/v3/queue?page={0}&pageSize=200&sortKey=timeleft";
|
||||
private const string QueueDeletePathTemplate = "/api/v3/queue/{0}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false";
|
||||
private const string SonarrCommandUriPath = "/api/v3/command";
|
||||
private const string SearchCommandPayloadTemplate = "{\"name\":\"SeriesSearch\",\"seriesId\":{0}}";
|
||||
|
||||
public BlockedTorrentHandler(
|
||||
ILogger<BlockedTorrentHandler> logger,
|
||||
IOptions<QBitConfig> qBitConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_qBitConfig = qBitConfig.Value;
|
||||
_sonarrConfig = sonarrConfig.Value;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
}
|
||||
|
||||
public async Task HandleAsync()
|
||||
{
|
||||
QBittorrentClient qBitClient = new(_qBitConfig.Url);
|
||||
|
||||
await qBitClient.LoginAsync(_qBitConfig.Username, _qBitConfig.Password);
|
||||
|
||||
foreach (SonarrInstance sonarrInstance in _sonarrConfig.Instances)
|
||||
{
|
||||
ushort page = 1;
|
||||
int totalRecords = 0;
|
||||
int processedRecords = 0;
|
||||
HashSet<int> seriesToBeRefreshed = [];
|
||||
|
||||
do
|
||||
{
|
||||
QueueListResponse queueResponse = await ListQueuedTorrentsAsync(sonarrInstance, page);
|
||||
|
||||
if (totalRecords is 0)
|
||||
{
|
||||
totalRecords = queueResponse.TotalRecords;
|
||||
|
||||
_logger.LogInformation(
|
||||
"{items} items found in queue | {url}",
|
||||
queueResponse.TotalRecords, sonarrInstance.Url);
|
||||
}
|
||||
|
||||
foreach (Record record in queueResponse.Records)
|
||||
{
|
||||
var torrent = (await qBitClient.GetTorrentListAsync(new TorrentListQuery { Hashes = [record.DownloadId] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (torrent is not { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
_logger.LogInformation("skip | {torrent}", record.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
seriesToBeRefreshed.Add(record.SeriesId);
|
||||
|
||||
await DeleteTorrentFromQueueAsync(sonarrInstance, record);
|
||||
}
|
||||
|
||||
if (queueResponse.Records.Count is 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
processedRecords += queueResponse.Records.Count;
|
||||
|
||||
if (processedRecords >= totalRecords)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (processedRecords < totalRecords);
|
||||
|
||||
foreach (int id in seriesToBeRefreshed)
|
||||
{
|
||||
await RefreshSeriesAsync(sonarrInstance, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<QueueListResponse> ListQueuedTorrentsAsync(SonarrInstance sonarrInstance, int page)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueListPathTemplate, page));
|
||||
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Get, sonarrUri);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue list failed | {uri}", sonarrUri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized response | {responseBody}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
private async Task DeleteTorrentFromQueueAsync(SonarrInstance sonarrInstance, Record record)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueDeletePathTemplate, record.Id));
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Delete, sonarrUri);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("queue item deleted | {record}", record.Title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue delete failed | {uri}", sonarrUri);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshSeriesAsync(SonarrInstance sonarrInstance, int seriesId)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, SonarrCommandUriPath);
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Post, sonarrUri);
|
||||
sonarrRequest.Content = new StringContent(
|
||||
SearchCommandPayloadTemplate.Replace("{0}", seriesId.ToString()),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("series search triggered | series id: {id}", seriesId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("series search failed | series id: {id}", seriesId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Common.Configuration;
|
||||
using Domain.Arr.Enums;
|
||||
using Domain.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
public sealed class QueueCleanerHandler
|
||||
{
|
||||
private readonly ILogger<QueueCleanerHandler> _logger;
|
||||
private readonly QBitConfig _qBitConfig;
|
||||
private readonly SonarrConfig _sonarrConfig;
|
||||
private readonly RadarrConfig _radarrConfig;
|
||||
private readonly SonarrClient _sonarrClient;
|
||||
private readonly RadarrClient _radarrClient;
|
||||
|
||||
public QueueCleanerHandler(
|
||||
ILogger<QueueCleanerHandler> logger,
|
||||
IOptions<QBitConfig> qBitConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_qBitConfig = qBitConfig.Value;
|
||||
_sonarrConfig = sonarrConfig.Value;
|
||||
_radarrConfig = radarrConfig.Value;
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
}
|
||||
|
||||
public async Task HandleAsync()
|
||||
{
|
||||
QBittorrentClient qBitClient = new(_qBitConfig.Url);
|
||||
await qBitClient.LoginAsync(_qBitConfig.Username, _qBitConfig.Password);
|
||||
|
||||
await ProcessArrConfigAsync(qBitClient, _sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(qBitClient, _radarrConfig, InstanceType.Radarr);
|
||||
}
|
||||
|
||||
private async Task ProcessArrConfigAsync(QBittorrentClient qBitClient, ArrConfig config, InstanceType instanceType)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ArrInstance arrInstance in config.Instances)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessInstanceAsync(qBitClient, arrInstance, instanceType);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessInstanceAsync(QBittorrentClient qBitClient, ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
ushort page = 1;
|
||||
int totalRecords = 0;
|
||||
int processedRecords = 0;
|
||||
HashSet<int> itemsToBeRefreshed = [];
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
|
||||
do
|
||||
{
|
||||
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(instance, page);
|
||||
|
||||
if (totalRecords is 0)
|
||||
{
|
||||
totalRecords = queueResponse.TotalRecords;
|
||||
|
||||
_logger.LogInformation(
|
||||
"{items} items found in queue | {url}",
|
||||
queueResponse.TotalRecords, instance.Url);
|
||||
}
|
||||
|
||||
foreach (QueueRecord record in queueResponse.Records)
|
||||
{
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TorrentInfo? 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;
|
||||
}
|
||||
|
||||
itemsToBeRefreshed.Add(GetRecordId(instanceType, record));
|
||||
|
||||
await arrClient.DeleteQueueItemAsync(instance, record);
|
||||
}
|
||||
|
||||
if (queueResponse.Records.Count is 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
processedRecords += queueResponse.Records.Count;
|
||||
|
||||
if (processedRecords >= totalRecords)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (processedRecords < totalRecords);
|
||||
|
||||
await arrClient.RefreshItemsAsync(instance, itemsToBeRefreshed);
|
||||
}
|
||||
|
||||
private ArrClient GetClient(InstanceType type) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => _sonarrClient,
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
|
||||
private int GetRecordId(InstanceType type, QueueRecord record) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => record.SeriesId,
|
||||
InstanceType.Radarr => record.MovieId,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user