Compare commits

...

13 Commits

Author SHA1 Message Date
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
33 changed files with 1013 additions and 467 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

640
README.md
View File

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

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

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

View File

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

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,8 @@
namespace Common.Configuration;
public sealed class TriggersConfig
{
public const string SectionName = "Triggers";
public required string QueueCleaner { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace Domain.Arr.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,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; }
}

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

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

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

View File

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

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>

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

View File

@@ -5,7 +5,7 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
"QuartzConfig": {
"BlockedTorrentTrigger": "0 0/1 * * * ?"
"Triggers": {
"QueueCleaner": "0 0/1 * * * ?"
}
}

View File

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

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

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

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