Compare commits

...

17 Commits

Author SHA1 Message Date
cousclou 75492a5792 update list 2025-03-28 18:02:12 +01:00
Flaminel 5ca717d7e0 Update README.md 2025-03-27 19:53:57 +02:00
Flaminel 7068ee5e5a Update README.md 2025-03-26 13:30:55 +02:00
Flaminel 9f770473e5 Remove Transmission downloads cache (#105) 2025-03-26 00:26:10 +02:00
Flaminel 5fe0f5750a Fix qBit queued items being processed (#102) 2025-03-21 23:06:31 +02:00
Flaminel b8ce225ccc Fix Deluge service crashing when download is not found (#97) 2025-03-20 00:09:58 +02:00
Flaminel f21f7388b7 Add download client customizable url base (#43) 2025-03-20 00:09:24 +02:00
Flaminel a1354f231a Add base path support for arrs (#96) 2025-03-20 00:08:51 +02:00
Flaminel 4bc1c33e81 Add option to explicitly disable the download client (#93) 2025-03-19 16:02:46 +02:00
Flaminel 32bcbab523 added docs for FreeBSD 2025-03-19 01:26:04 +02:00
Flaminel b94ae21e11 update permissive blacklist 2025-03-13 10:16:52 +02:00
Flaminel a92ebd75c2 Update docs (#88) 2025-03-11 23:42:21 +02:00
Flaminel e6d3929fc9 Restrict max strikes to a minimum value (#87) 2025-03-11 23:35:07 +02:00
Flaminel a68e13af35 Fix notifications when poster is not found (#89) 2025-03-11 23:34:44 +02:00
Flaminel 324c3ace8f Fix multiple runs on queue cleaner when download cleaner is enabled (#90) 2025-03-11 23:34:27 +02:00
Flaminel 3a9d5d9085 Fix patterns being loaded for disabled arrs (#80) 2025-03-11 23:18:34 +02:00
Flaminel 89a6eaf0ce Disable cleanup on torrent items if download client is not configured (#85) 2025-03-10 00:13:40 +02:00
34 changed files with 655 additions and 385 deletions
+146 -35
View File
@@ -16,6 +16,7 @@ cleanuperr was created primarily to address malicious files, such as `*.lnk` or
> - Trigger a search for downloads removed from the *arrs. > - Trigger a search for downloads removed from the *arrs.
> - Clean up downloads that have been seeding for a certain amount of time. > - Clean up downloads that have been seeding for a certain amount of time.
> - Notify on strike or download removal. > - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from processing.
cleanuperr 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. cleanuperr 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.
@@ -25,15 +26,22 @@ cleanuperr supports both qBittorrent's built-in exclusion features and its own b
> https://discord.gg/sWggpnmGNY > https://discord.gg/sWggpnmGNY
## Table of contents: ## Table of contents:
- [Naming choice](README.md#naming-choice) - [Naming choice](#naming-choice)
- [Quick Start](README.md#quick-start) - [Quick Start](#quick-start)
- [How it works](README.md#how-it-works) - [How it works](#how-it-works)
- [Setup](README.md#setup) - [Content blocker](#1-content-blocker-will)
- [Usage](README.md#usage) - [Queue cleaner](#2-queue-cleaner-will)
- [Docker Compose](README.md#docker-compose-yaml) - [Download cleaner](#3-download-cleaner-will)
- [Environment Variables](README.md#environment-variables) - [Setup](#setup-examples)
- [Binaries](README.md#binaries-if-youre-not-using-docker) - [Usage](#usage)
- [Credits](README.md#credits) - [Docker](#docker)
- [Environment Variables](#environment-variables)
- [Docker Compose](#docker-compose-example)
- [Windows](#windows)
- [Linux](#linux)
- [MacOS](#macos)
- [FreeBSD](#freebsd)
- [Credits](#credits)
## Naming choice ## Naming choice
@@ -52,10 +60,10 @@ I've seen a few discussions on this type of naming and I've decided that I didn'
> Use the Unraid Community App. > Use the Unraid Community App.
> >
> 3. **Manual Installation (if you're not using Docker)** > 3. **Manual Installation (if you're not using Docker)**
> More details [here](#binaries-if-youre-not-using-docker). > Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
> [!TIP] > [!TIP]
> 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. > Refer to the [Environment variables](#environment-variables) section for detailed configuration instructions and the [Setup examples](#setup-examples) section for an in-depth explanation of the cleanup process.
> [!IMPORTANT] > [!IMPORTANT]
@@ -69,7 +77,7 @@ I've seen a few discussions on this type of naming and I've decided that I didn'
# How it works # How it works
1. **Content blocker** will: #### 1. **Content blocker** will:
- Run every 5 minutes (or configured cron). - Run every 5 minutes (or configured cron).
- Process all items in the *arr queue. - Process all items in the *arr queue.
- Find the corresponding item from the download client for each queue item. - Find the corresponding item from the download client for each queue item.
@@ -80,24 +88,24 @@ I've seen a few discussions on this type of naming and I've decided that I didn'
- It will be removed from the *arr's queue and blocked. - It will be removed from the *arr's queue and blocked.
- It will be deleted from the download client. - It will be deleted from the download client.
- A new search will be triggered for the *arr item. - A new search will be triggered for the *arr item.
2. **Queue cleaner** will: #### 2. **Queue cleaner** will:
- Run every 5 minutes (or configured cron, or right after `content blocker`). - Run every 5 minutes (or configured cron, or right after `content blocker`).
- Process all items in the *arr queue. - Process all items in the *arr queue.
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading** or **failed to be imported**. - Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading** or **failed to be imported**.
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions. - If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
- Check each queue item if it meets one of the following condition in the download client: - 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**). - **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**. - All associated files are marked as **unwanted/skipped/do not download**.
- If the item **DOES NOT** match the above criteria, it will be skipped. - If the item **DOES NOT** match the above criteria, it will be skipped.
- If the item **DOES** match the criteria or has received the **maximum number of strikes**: - If the item **DOES** match the criteria or has received the **maximum number of strikes**:
- It will be removed from the *arr's queue and blocked. - It will be removed from the *arr's queue and blocked.
- It will be deleted from the download client. - It will be deleted from the download client.
- A new search will be triggered for the *arr item. - A new search will be triggered for the *arr item.
3. **Download cleaner** will: #### 3. **Download cleaner** will:
- Run every hour (or configured cron). - Run every hour (or configured cron).
- Automatically clean up downloads that have been seeding for a certain amount of time. - Automatically clean up downloads that have been seeding for a certain amount of time.
# Setup # Setup examples
## Using qBittorrent's built-in feature (works only with qBittorrent) ## Using qBittorrent's built-in feature (works only with qBittorrent)
@@ -129,7 +137,26 @@ I've seen a few discussions on this type of naming and I've decided that I didn'
## Usage ## Usage
### Docker compose yaml ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/docker.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">Docker</span>
### **Environment variables**
**Jump to:**
- [General settings](variables.md#general-settings)
- [Queue Cleaner settings](variables.md#queue-cleaner-settings)
- [Content Blocker settings](variables.md#content-blocker-settings)
- [Download Cleaner settings](variables.md#download-cleaner-settings)
- [Download Client settings](variables.md#download-client-settings)
- [Arr settings](variables.md#arr-settings)
- [Notification settings](variables.md#notification-settings)
- [Advanced settings](variables.md#advanced-settings)
### Docker compose example
> [!NOTE]
>
> This example contains all settings and should be modified to fit your needs.
``` ```
version: "3.3" version: "3.3"
@@ -187,15 +214,18 @@ services:
# OR # OR
# - DOWNLOAD_CLIENT=qBittorrent # - DOWNLOAD_CLIENT=qBittorrent
# - QBITTORRENT__URL=http://localhost:8080 # - QBITTORRENT__URL=http://localhost:8080
# - QBITTORRENT__URL_BASE=myCustomPath
# - QBITTORRENT__USERNAME=user # - QBITTORRENT__USERNAME=user
# - QBITTORRENT__PASSWORD=pass # - QBITTORRENT__PASSWORD=pass
# OR # OR
# - DOWNLOAD_CLIENT=deluge # - DOWNLOAD_CLIENT=deluge
# - DELUGE__URL_BASE=myCustomPath
# - DELUGE__URL=http://localhost:8112 # - DELUGE__URL=http://localhost:8112
# - DELUGE__PASSWORD=testing # - DELUGE__PASSWORD=testing
# OR # OR
# - DOWNLOAD_CLIENT=transmission # - DOWNLOAD_CLIENT=transmission
# - TRANSMISSION__URL=http://localhost:9091 # - TRANSMISSION__URL=http://localhost:9091
# - TRANSMISSION__URL_BASE=myCustomPath
# - TRANSMISSION__USERNAME=test # - TRANSMISSION__USERNAME=test
# - TRANSMISSION__PASSWORD=testing # - TRANSMISSION__PASSWORD=testing
@@ -232,27 +262,109 @@ services:
- NOTIFIARR__CHANNEL_ID=discord_channel_id - NOTIFIARR__CHANNEL_ID=discord_channel_id
``` ```
## Environment variables ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/windows.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">Windows</span>
Jump to: 1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
- [General settings](variables.md#general-settings) 2. Extract the zip file into `C:\example\directory`.
- [Queue Cleaner settings](variables.md#queue-cleaner-settings) 3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
- [Content Blocker settings](variables.md#content-blocker-settings) 4. Execute `cleanuperr.exe`.
- [Download Cleaner settings](variables.md#download-cleaner-settings)
- [Download Client settings](variables.md#download-client-settings)
- [Arr settings](variables.md#arr-settings)
- [Notification settings](variables.md#notification-settings)
- [Advanced settings](variables.md#advanced-settings)
### Binaries (if you're not using Docker)
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).
> [!TIP] > [!TIP]
> ### Run as a Windows Service > ### Run as a Windows Service
> Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678 > 1. Download latest nssm build from `https://nssm.cc/builds`.
> 2. Unzip `nssm.exe` in `C:\example\directory`.
> 3. Open a terminal with Administrator rights and execute these commands:
> ```
> nssm.exe install Cleanuperr "C:\example\directory\cleanuperr.exe"
> nssm.exe set Cleanuperr AppDirectory "C:\example\directory\"
> nssm.exe set Cleanuperr AppStdout "C:\example\directory\cleanuperr.log"
> nssm.exe set Cleanuperr AppStderr "C:\example\directory\cleanuperr.crash.log"
> nssm.exe set Cleanuperr AppRotateFiles 1
> nssm.exe set Cleanuperr AppRotateOnline 1
> nssm.exe set Cleanuperr AppRotateBytes 10485760
> nssm.exe set Cleanuperr AppRotateFiles 10
> nssm.exe set Cleanuperr Start SERVICE_AUTO_START
> nssm.exe start Cleanuperr
> ```
### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/linux.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">Linux</span>
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
2. Extract the zip file into `/example/directory`.
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
4. Open a terminal and execute these commands:
```
cd /example/directory
chmod +x cleanuperr
./cleanuperr
```
### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/apple.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">MacOS</span>
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
2. Extract the zip file into `/example/directory`.
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
4. Open a terminal and execute these commands:
```
cd /example/directory
chmod +x cleanuperr
./cleanuperr
```
> [!IMPORTANT]
> Some people have experienced problems when trying to execute cleanuperr on MacOS because the system actively blocked the file for not being signed.
> As per [this](), you may need to also execute this command:
> ```
> codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime /example/directory/cleanuperr
> ```
### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/freebsd.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">FreeBSD</span>
1. Installation:
```
# install dependencies
pkg install -y git icu libinotify libunwind wget
# set up the dotnet SDK
cd ~
wget -q https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/dotnet-sdk-9.0.104-freebsd-x64.tar.gz
export DOTNET_ROOT=$(pwd)/.dotnet
mkdir -p "$DOTNET_ROOT" && tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools
# download NuGet dependencies
mkdir -p /tmp/nuget
wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.AspNetCore.App.Runtime.freebsd-x64.9.0.3.nupkg
wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.NETCore.App.Host.freebsd-x64.9.0.3.nupkg
wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.NETCore.App.Runtime.freebsd-x64.9.0.3.nupkg
# add NuGet source
dotnet nuget add source /tmp/nuget --name tmp
# add GitHub NuGet source
# a PAT (Personal Access Token) can be generated here https://github.com/settings/tokens
dotnet nuget add source --username <YOUR_USERNAME> --password <YOUR_PERSONAL_ACCESS_TOKEN> --store-password-in-clear-text --name flmorg https://nuget.pkg.github.com/flmorg/index.json
```
2. Building:
```
# clone the project
git clone https://github.com/flmorg/cleanuperr.git
cd cleanuperr
# build and publish the app
dotnet publish code/Executable/Executable.csproj -c Release --self-contained -o artifacts /p:PublishSingleFile=true
# move the files to permanent destination
mv artifacts/cleanuperr /example/directory/
mv artifacts/appsettings.json /example/directory/
```
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
4. Run the app:
```
cd /example/directory
chmod +x cleanuperr
./cleanuperr
```
# Credits # Credits
Special thanks for inspiration go to: Special thanks for inspiration go to:
@@ -265,4 +377,3 @@ Special thanks for inspiration go to:
If I made your life just a tiny bit easier, consider buying me a coffee! If I made your life just a tiny bit easier, consider buying me a coffee!
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a> <a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
-6
View File
@@ -216,8 +216,6 @@
*.log *.log
*.loop-vbs *.loop-vbs
*.ls *.ls
*.m3u
*.m4a
*.mac *.mac
*.macho *.macho
*.mamc *.mamc
@@ -271,7 +269,6 @@
*.ncl *.ncl
*.net *.net
*.nexe *.nexe
*.nfo
*.nrg *.nrg
*.num *.num
*.nzb.bz2 *.nzb.bz2
@@ -402,7 +399,6 @@
*.sql *.sql
*.sqx *.sqx
*.srec *.srec
*.srt
*.ssm *.ssm
*.sts *.sts
*.sub *.sub
@@ -514,6 +510,4 @@
*sample.mp4 *sample.mp4
*sample.webm *sample.webm
*sample.wmv *sample.wmv
Trailer.*
VOSTFR
api api
+1
View File
@@ -1,4 +1,5 @@
*.apk *.apk
*.arj
*.bat *.bat
*.bin *.bin
*.bmp *.bmp
@@ -1,4 +1,5 @@
using Common.Exceptions; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient; namespace Common.Configuration.DownloadClient;
@@ -8,6 +9,9 @@ public sealed record DelugeConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Password { get; init; } public string? Password { get; init; }
public void Validate() public void Validate()
@@ -1,4 +1,5 @@
using Common.Exceptions; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient; namespace Common.Configuration.DownloadClient;
@@ -8,6 +9,9 @@ public sealed class QBitConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Username { get; init; } public string? Username { get; init; }
public string? Password { get; init; } public string? Password { get; init; }
@@ -1,4 +1,5 @@
using Common.Exceptions; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient; namespace Common.Configuration.DownloadClient;
@@ -8,6 +9,9 @@ public record TransmissionConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = "transmission";
public string? Username { get; init; } public string? Username { get; init; }
public string? Password { get; init; } public string? Password { get; init; }
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Configuration; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.QueueCleaner; namespace Common.Configuration.QueueCleaner;
@@ -39,5 +40,14 @@ public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
public void Validate() public void Validate()
{ {
if (ImportFailedMaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for IMPORT_FAILED_MAX_STRIKES must be 3");
}
if (StalledMaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for STALLED_MAX_STRIKES must be 3");
}
} }
} }
+2 -1
View File
@@ -5,5 +5,6 @@ public enum DownloadClient
QBittorrent, QBittorrent,
Deluge, Deluge,
Transmission, Transmission,
None None,
Disabled
} }
+5 -1
View File
@@ -2,7 +2,11 @@
public enum DeleteReason public enum DeleteReason
{ {
None,
Stalled, Stalled,
ImportFailed, ImportFailed,
AllFilesBlocked DownloadingMetadata,
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
} }
+1
View File
@@ -3,5 +3,6 @@
public enum StrikeType public enum StrikeType
{ {
Stalled, Stalled,
DownloadingMetadata,
ImportFailed ImportFailed
} }
@@ -55,7 +55,7 @@ public static class QuartzDI
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true }) if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
{ {
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty); q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
q.AddJobListener(new JobChainingListener(nameof(QueueCleaner))); q.AddJobListener(new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner)));
} }
else else
{ {
@@ -52,15 +52,18 @@
"DOWNLOAD_CLIENT": "qbittorrent", "DOWNLOAD_CLIENT": "qbittorrent",
"qBittorrent": { "qBittorrent": {
"Url": "http://localhost:8080", "Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
"Deluge": { "Deluge": {
"Url": "http://localhost:8112", "Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing" "Password": "testing"
}, },
"Transmission": { "Transmission": {
"Url": "http://localhost:9091", "Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
+3
View File
@@ -42,15 +42,18 @@
"DOWNLOAD_CLIENT": "none", "DOWNLOAD_CLIENT": "none",
"qBittorrent": { "qBittorrent": {
"Url": "http://localhost:8080", "Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "", "Username": "",
"Password": "" "Password": ""
}, },
"Deluge": { "Deluge": {
"Url": "http://localhost:8112", "Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing" "Password": "testing"
}, },
"Transmission": { "Transmission": {
"Url": "http://localhost:9091", "Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
@@ -105,13 +105,14 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
// Arrange // Arrange
const string hash = "test-hash"; const string hash = "test-hash";
const string itemName = "test-item"; const string itemName = "test-item";
_fixture.Striker.StrikeAndCheckLimit(hash, itemName, 3, StrikeType.Stalled) StrikeType strikeType = StrikeType.Stalled;
_fixture.Striker.StrikeAndCheckLimit(hash, itemName, 3, strikeType)
.Returns(true); .Returns(true);
TestDownloadService sut = _fixture.CreateSut(); TestDownloadService sut = _fixture.CreateSut();
// Act // Act
bool result = await sut.StrikeAndCheckLimit(hash, itemName); bool result = await sut.StrikeAndCheckLimit(hash, itemName, strikeType);
// Assert // Assert
result.ShouldBeTrue(); result.ShouldBeTrue();
@@ -3,6 +3,7 @@ using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner; using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Infrastructure.Interceptors; using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.DownloadClient;
@@ -45,6 +46,6 @@ public class TestDownloadService : DownloadService
// Expose protected methods for testing // Expose protected methods for testing
public new void ResetStrikesOnProgress(string hash, long downloaded) => base.ResetStrikesOnProgress(hash, downloaded); public new void ResetStrikesOnProgress(string hash, long downloaded) => base.ResetStrikesOnProgress(hash, downloaded);
public new Task<bool> StrikeAndCheckLimit(string hash, string itemName) => base.StrikeAndCheckLimit(hash, itemName); public new Task<bool> StrikeAndCheckLimit(string hash, string itemName, StrikeType strikeType) => base.StrikeAndCheckLimit(hash, itemName, strikeType);
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category) => base.ShouldCleanDownload(ratio, seedingTime, category); public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category) => base.ShouldCleanDownload(ratio, seedingTime, category);
} }
+1 -1
View File
@@ -13,7 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.0" /> <PackageReference Include="FLM.QBittorrent" Version="1.0.0" />
<PackageReference Include="FLM.Transmission" Version="1.0.2" /> <PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" /> <PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit" Version="8.3.6" /> <PackageReference Include="MassTransit" Version="8.3.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
+26 -12
View File
@@ -43,9 +43,11 @@ public abstract class ArrClient : IArrClient
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page) public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{ {
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page)); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
using HttpRequestMessage request = new(HttpMethod.Get, uri); using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -56,7 +58,7 @@ public abstract class ArrClient : IArrClient
} }
catch catch
{ {
_logger.LogError("queue list failed | {uri}", uri); _logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
throw; throw;
} }
@@ -65,7 +67,7 @@ public abstract class ArrClient : IArrClient
if (queueResponse is null) if (queueResponse is null)
{ {
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}"); throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
} }
return queueResponse; return queueResponse;
@@ -112,13 +114,20 @@ public abstract class ArrClient : IArrClient
return false; return false;
} }
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient) public virtual async Task DeleteQueueItemAsync(
ArrInstance arrInstance,
QueueRecord record,
bool removeFromClient,
DeleteReason deleteReason
)
{ {
Uri uri = new(arrInstance.Url, GetQueueDeleteUrlPath(record.Id, removeFromClient)); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueDeleteUrlPath(record.Id).TrimStart('/')}";
uriBuilder.Query = GetQueueDeleteUrlQuery(removeFromClient);
try try
{ {
using HttpRequestMessage request = new(HttpMethod.Delete, uri); using HttpRequestMessage request = new(HttpMethod.Delete, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request); HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
@@ -126,15 +135,16 @@ public abstract class ArrClient : IArrClient
_logger.LogInformation( _logger.LogInformation(
removeFromClient removeFromClient
? "queue item deleted | {url} | {title}" ? "queue item deleted with reason {reason} | {url} | {title}"
: "queue item removed from arr | {url} | {title}", : "queue item removed from arr with reason {reason} | {url} | {title}",
deleteReason.ToString(),
arrInstance.Url, arrInstance.Url,
record.Title record.Title
); );
} }
catch catch
{ {
_logger.LogError("queue delete failed | {uri} | {title}", uri, record.Title); _logger.LogError("queue delete failed | {uri} | {title}", uriBuilder.Uri, record.Title);
throw; throw;
} }
} }
@@ -152,9 +162,13 @@ public abstract class ArrClient : IArrClient
return true; return true;
} }
protected abstract string GetQueueUrlPath(int page); protected abstract string GetQueueUrlPath();
protected abstract string GetQueueDeleteUrlPath(long recordId, bool removeFromClient); protected abstract string GetQueueUrlQuery(int page);
protected abstract string GetQueueDeleteUrlPath(long recordId);
protected abstract string GetQueueDeleteUrlQuery(bool removeFromClient);
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey) protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{ {
@@ -11,7 +11,7 @@ public interface IArrClient
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload); Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient); Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items); Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
@@ -27,29 +27,42 @@ public class LidarrClient : ArrClient, ILidarrClient
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v1/queue?page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true"; return "/api/v1/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v1/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{ {
if (items?.Count is null or 0) return; if (items?.Count is null or 0)
{
return;
}
Uri uri = new(arrInstance.Url, "/api/v1/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
foreach (var command in GetSearchCommands(items)) foreach (var command in GetSearchCommands(items))
{ {
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8, Encoding.UTF8,
@@ -132,8 +145,11 @@ public class LidarrClient : ArrClient, ILidarrClient
private async Task<List<Album>?> GetAlbumsAsync(ArrInstance arrInstance, List<long> albumIds) private async Task<List<Album>?> GetAlbumsAsync(ArrInstance arrInstance, List<long> albumIds)
{ {
Uri uri = new(arrInstance.Url, $"api/v1/album?{string.Join('&', albumIds.Select(x => $"albumIds={x}"))}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album";
uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using var response = await _httpClient.SendAsync(request); using var response = await _httpClient.SendAsync(request);
@@ -27,18 +27,27 @@ public class RadarrClient : ArrClient, IRadarrClient
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true"; return "/api/v3/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v3/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return path; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
@@ -50,14 +59,16 @@ public class RadarrClient : ArrClient, IRadarrClient
List<long> ids = items.Select(item => item.Id).ToList(); List<long> ids = items.Select(item => item.Id).ToList();
Uri uri = new(arrInstance.Url, "/api/v3/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
RadarrCommand command = new() RadarrCommand command = new()
{ {
Name = "MoviesSearch", Name = "MoviesSearch",
MovieIds = ids, MovieIds = ids,
}; };
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command), JsonConvert.SerializeObject(command),
Encoding.UTF8, Encoding.UTF8,
@@ -135,8 +146,10 @@ public class RadarrClient : ArrClient, IRadarrClient
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId) private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/movie/{movieId}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -28,18 +28,27 @@ public class SonarrClient : ArrClient, ISonarrClient
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true"; return "/api/v3/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v3/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
@@ -49,11 +58,12 @@ public class SonarrClient : ArrClient, ISonarrClient
return; return;
} }
Uri uri = new(arrInstance.Url, "/api/v3/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet())) foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
{ {
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8, Encoding.UTF8,
@@ -199,8 +209,11 @@ public class SonarrClient : ArrClient, ISonarrClient
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds) private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/episode?{string.Join('&', episodeIds.Select(x => $"episodeIds={x}"))}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -212,8 +225,10 @@ public class SonarrClient : ArrClient, ISonarrClient
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId) private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/series/{seriesId}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -49,9 +49,9 @@ public sealed class BlocklistProvider
try try
{ {
await LoadPatternsAndRegexesAsync(_sonarrConfig.Block.Type, _sonarrConfig.Block.Path, InstanceType.Sonarr); await LoadPatternsAndRegexesAsync(_sonarrConfig, InstanceType.Sonarr);
await LoadPatternsAndRegexesAsync(_radarrConfig.Block.Type, _radarrConfig.Block.Path, InstanceType.Radarr); await LoadPatternsAndRegexesAsync(_radarrConfig, InstanceType.Radarr);
await LoadPatternsAndRegexesAsync(_lidarrConfig.Block.Type, _lidarrConfig.Block.Path, InstanceType.Lidarr); await LoadPatternsAndRegexesAsync(_lidarrConfig, InstanceType.Lidarr);
_initialized = true; _initialized = true;
} }
@@ -83,14 +83,19 @@ public sealed class BlocklistProvider
return regexes ?? []; return regexes ?? [];
} }
private async Task LoadPatternsAndRegexesAsync(BlocklistType blocklistType, string? blocklistPath, InstanceType instanceType) private async Task LoadPatternsAndRegexesAsync(ArrConfig arrConfig, InstanceType instanceType)
{ {
if (string.IsNullOrEmpty(blocklistPath)) if (!arrConfig.Enabled)
{ {
return; return;
} }
string[] filePatterns = await ReadContentAsync(blocklistPath); if (string.IsNullOrEmpty(arrConfig.Block.Path))
{
return;
}
string[] filePatterns = await ReadContentAsync(arrConfig.Block.Path);
long startTime = Stopwatch.GetTimestamp(); long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 }; ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
@@ -121,13 +126,13 @@ public sealed class BlocklistProvider
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime); TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.BlocklistType(instanceType), blocklistType); _cache.Set(CacheKeys.BlocklistType(instanceType), arrConfig.Block.Type);
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns); _cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
_cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes); _cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes);
_logger.LogDebug("loaded {count} patterns", patterns.Count); _logger.LogDebug("loaded {count} patterns", patterns.Count);
_logger.LogDebug("loaded {count} regexes", regexes.Count); _logger.LogDebug("loaded {count} regexes", regexes.Count);
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistPath); _logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, arrConfig.Block.Path);
} }
private async Task<string[]> ReadContentAsync(string path) private async Task<string[]> ReadContentAsync(string path)
@@ -55,9 +55,9 @@ public sealed class ContentBlocker : GenericHandler
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None) if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled)
{ {
_logger.LogWarning("download client is set to none"); _logger.LogWarning("download client is not set");
return; return;
} }
@@ -142,7 +142,7 @@ public sealed class ContentBlocker : GenericHandler
removeFromClient = false; removeFromClient = false;
} }
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient, DeleteReason.AllFilesBlocked);
await _notifier.NotifyQueueItemDeleted(removeFromClient, DeleteReason.AllFilesBlocked); await _notifier.NotifyQueueItemDeleted(removeFromClient, DeleteReason.AllFilesBlocked);
} }
}); });
@@ -50,9 +50,9 @@ public sealed class DownloadCleaner : GenericHandler
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None) if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled)
{ {
_logger.LogWarning("download client is set to none"); _logger.LogWarning("download client is not set");
return; return;
} }
@@ -33,6 +33,7 @@ public sealed class DelugeClient
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory) public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
{ {
_config = config.Value; _config = config.Value;
_config.Validate();
_httpClient = httpClientFactory.CreateClient(nameof(DelugeService)); _httpClient = httpClientFactory.CreateClient(nameof(DelugeService));
} }
@@ -79,11 +80,24 @@ public sealed class DelugeClient
public async Task<TorrentStatus?> GetTorrentStatus(string hash) public async Task<TorrentStatus?> GetTorrentStatus(string hash)
{ {
return await SendRequest<TorrentStatus?>( try
"web.get_torrent_status", {
hash, return await SendRequest<TorrentStatus?>(
Fields "web.get_torrent_status",
); hash,
Fields
);
}
catch (DelugeClientException e)
{
// Deluge returns an error when the torrent is not found
if (e.Message == "AttributeError: 'NoneType' object has no attribute 'call'")
{
return null;
}
throw;
}
} }
public async Task<List<TorrentStatus>?> GetStatusForAllTorrents() public async Task<List<TorrentStatus>?> GetStatusForAllTorrents()
@@ -121,8 +135,12 @@ public sealed class DelugeClient
{ {
StringContent content = new StringContent(json); StringContent content = new StringContent(json);
content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json"); content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json");
var responseMessage = await _httpClient.PostAsync(new Uri(_config.Url, "/json"), content); UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/json"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/json";
var responseMessage = await _httpClient.PostAsync(uriBuilder.Uri, content);
responseMessage.EnsureSuccessStatusCode(); responseMessage.EnsureSuccessStatusCode();
var responseJson = await responseMessage.Content.ReadAsStringAsync(); var responseJson = await responseMessage.Content.ReadAsStringAsync();
@@ -65,6 +65,8 @@ public class DelugeService : DownloadService, IDelugeService
return result; return result;
} }
result.IsPrivate = download.Private;
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{ {
_logger.LogInformation("skip | download is ignored | {name}", download.Name); _logger.LogInformation("skip | download is ignored | {name}", download.Name);
@@ -79,6 +81,7 @@ public class DelugeService : DownloadService, IDelugeService
{ {
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash); _logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
} }
bool shouldRemove = contents?.Contents?.Count > 0; bool shouldRemove = contents?.Contents?.Count > 0;
@@ -92,17 +95,15 @@ public class DelugeService : DownloadService, IDelugeService
if (shouldRemove) if (shouldRemove)
{ {
result.DeleteReason = DeleteReason.AllFilesBlocked; // remove if all files are unwanted
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
} }
result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(download); // remove if download is stuck
result.IsPrivate = download.Private; (result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download);
if (!shouldRemove && result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
@@ -295,33 +296,33 @@ public class DelugeService : DownloadService, IDelugeService
await _client.ChangeFilesPriority(hash, sortedPriorities); await _client.ChangeFilesPriority(hash, sortedPriorities);
} }
private async Task<bool> IsItemStuckAndShouldRemove(TorrentStatus status) private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentStatus status)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
return false; return (false, default);
} }
if (_queueCleanerConfig.StalledIgnorePrivate && status.Private) if (_queueCleanerConfig.StalledIgnorePrivate && status.Private)
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name); _logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
return false; return (false, default);
} }
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase)) if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{ {
return false; return (false, default);
} }
if (status.Eta > 0) if (status.Eta > 0)
{ {
return false; return (false, default);
} }
ResetStrikesOnProgress(status.Hash!, status.TotalDone); ResetStrikesOnProgress(status.Hash!, status.TotalDone);
return await StrikeAndCheckLimit(status.Hash!, status.Name!); return (await StrikeAndCheckLimit(status.Hash!, status.Name!, StrikeType.Stalled), DeleteReason.Stalled);
} }
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile) private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile)
@@ -100,10 +100,11 @@ public abstract class DownloadService : IDownloadService
/// </summary> /// </summary>
/// <param name="hash">The torrent hash.</param> /// <param name="hash">The torrent hash.</param>
/// <param name="itemName">The name or title of the item.</param> /// <param name="itemName">The name or title of the item.</param>
/// <param name="strikeType"></param>
/// <returns>True if the limit has been reached; otherwise, false.</returns> /// <returns>True if the limit has been reached; otherwise, false.</returns>
protected async Task<bool> StrikeAndCheckLimit(string hash, string itemName) protected async Task<bool> StrikeAndCheckLimit(string hash, string itemName, StrikeType strikeType)
{ {
return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled); return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, strikeType);
} }
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category) protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category)
@@ -25,6 +25,7 @@ public sealed class DownloadServiceFactory
Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(), Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(),
Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(), Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(),
Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService<DummyDownloadService>(), Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService<DummyDownloadService>(),
Common.Enums.DownloadClient.Disabled => _serviceProvider.GetRequiredService<DummyDownloadService>(),
_ => throw new ArgumentOutOfRangeException() _ => throw new ArgumentOutOfRangeException()
}; };
} }
@@ -45,7 +45,11 @@ public class QBitService : DownloadService, IQBitService
{ {
_config = config.Value; _config = config.Value;
_config.Validate(); _config.Validate();
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), _config.Url); UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? uriBuilder.Path
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/')}";
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri);
} }
public override async Task LoginAsync() public override async Task LoginAsync()
@@ -92,30 +96,26 @@ public class QBitService : DownloadService, IQBitService
bool.TryParse(dictValue?.ToString(), out bool boolValue) bool.TryParse(dictValue?.ToString(), out bool boolValue)
&& boolValue; && boolValue;
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
return result;
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash); IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
// if all files are marked as skip
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
{ {
result.ShouldRemove = true; result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
return result;
}
// remove if all files are unwanted
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result; return result;
} }
result.ShouldRemove = await IsItemStuckAndShouldRemove(download, result.IsPrivate); // remove if download is stuck
(result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download, result.IsPrivate);
if (result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
@@ -333,30 +333,35 @@ public class QBitService : DownloadService, IQBitService
_client.Dispose(); _client.Dispose();
} }
private async Task<bool> IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate) private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
return false; return (false, default);
} }
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate) if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
return false; return (false, default);
} }
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
and not TorrentState.ForcedFetchingMetadata) and not TorrentState.ForcedFetchingMetadata)
{ {
// ignore other states // ignore other states
return false; return (false, default);
} }
ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0); ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
return await StrikeAndCheckLimit(torrent.Hash, torrent.Name); if (torrent.State is TorrentState.StalledDownload)
{
return (await StrikeAndCheckLimit(torrent.Hash, torrent.Name, StrikeType.Stalled), DeleteReason.Stalled);
}
return (await StrikeAndCheckLimit(torrent.Hash, torrent.Name, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
} }
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash) private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
@@ -26,7 +26,6 @@ public class TransmissionService : DownloadService, ITransmissionService
{ {
private readonly TransmissionConfig _config; private readonly TransmissionConfig _config;
private readonly Client _client; private readonly Client _client;
private TorrentInfo[]? _torrentsCache;
private static readonly string[] Fields = private static readonly string[] Fields =
[ [
@@ -64,9 +63,13 @@ public class TransmissionService : DownloadService, ITransmissionService
{ {
_config = config.Value; _config = config.Value;
_config.Validate(); _config.Validate();
UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/rpc"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/rpc";
_client = new( _client = new(
httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), httpClientFactory.CreateClient(Constants.HttpClientWithRetryName),
new Uri(_config.Url, "/transmission/rpc").ToString(), uriBuilder.Uri.ToString(),
login: _config.Username, login: _config.Username,
password: _config.Password password: _config.Password
); );
@@ -115,17 +118,15 @@ public class TransmissionService : DownloadService, ITransmissionService
if (shouldRemove) if (shouldRemove)
{ {
// remove if all files are unwanted
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked; result.DeleteReason = DeleteReason.AllFilesBlocked;
return result;
} }
// remove if all files are unwanted or download is stuck // remove if download is stuck
result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(download); (result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download);
if (!shouldRemove && result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
@@ -334,60 +335,38 @@ public class TransmissionService : DownloadService, ITransmissionService
}); });
} }
private async Task<bool> IsItemStuckAndShouldRemove(TorrentInfo torrent) private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentInfo torrent)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
return false; return (false, default);
} }
if (_queueCleanerConfig.StalledIgnorePrivate && (torrent.IsPrivate ?? false)) if (_queueCleanerConfig.StalledIgnorePrivate && (torrent.IsPrivate ?? false))
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
return false; return (false, default);
} }
if (torrent.Status is not 4) if (torrent.Status is not 4)
{ {
// not in downloading state // not in downloading state
return false; return (false, default);
} }
if (torrent.Eta > 0) if (torrent.Eta > 0)
{ {
return false; return (false, default);
} }
ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0); ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0);
return await StrikeAndCheckLimit(torrent.HashString!, torrent.Name!); return (await StrikeAndCheckLimit(torrent.HashString!, torrent.Name!, StrikeType.Stalled), DeleteReason.Stalled);
} }
private async Task<TorrentInfo?> GetTorrentAsync(string hash) private async Task<TorrentInfo?> GetTorrentAsync(string hash) =>
{ (await _client.TorrentGetAsync(Fields, hash))
TorrentInfo? torrent = _torrentsCache? ?.Torrents
.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase)); ?.FirstOrDefault();
if (_torrentsCache is null || torrent is null)
{
// 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;
}
} }
@@ -4,10 +4,12 @@ namespace Infrastructure.Verticals.Jobs;
public class JobChainingListener : IJobListener public class JobChainingListener : IJobListener
{ {
private readonly string _firstJobName;
private readonly string _nextJobName; private readonly string _nextJobName;
public JobChainingListener(string nextJobName) public JobChainingListener(string firstJobName, string nextJobName)
{ {
_firstJobName = firstJobName;
_nextJobName = nextJobName; _nextJobName = nextJobName;
} }
@@ -19,7 +21,7 @@ public class JobChainingListener : IJobListener
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken) public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName) if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName || context.JobDetail.Key.Name != _firstJobName)
{ {
return; return;
} }
@@ -128,9 +128,9 @@ public class NotificationPublisher : INotificationPublisher
{ {
Uri? image = instanceType switch Uri? image = instanceType switch
{ {
InstanceType.Sonarr => record.Series!.Images.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl, InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Radarr => record.Movie!.Images.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl, InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album!.Images.FirstOrDefault(x => x.CoverType == "cover")?.Url, InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType)) _ => throw new ArgumentOutOfRangeException(nameof(instanceType))
}; };
@@ -45,6 +45,7 @@ public sealed class QueueCleaner : GenericHandler
) )
{ {
_config = config.Value; _config = config.Value;
_config.Validate();
_ignoredDownloadsProvider = ignoredDownloadsProvider; _ignoredDownloadsProvider = ignoredDownloadsProvider;
} }
@@ -92,8 +93,14 @@ public sealed class QueueCleaner : GenericHandler
StalledResult stalledCheckResult = new(); StalledResult stalledCheckResult = new();
if (_downloadClientConfig.DownloadClient is not Common.Enums.DownloadClient.None && record.Protocol is "torrent") if (record.Protocol is "torrent" && _downloadClientConfig.DownloadClient is not Common.Enums.DownloadClient.Disabled)
{ {
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None)
{
_logger.LogWarning("skip | download client is not configured | {title}", record.Title);
continue;
}
// stalled download check // stalled download check
stalledCheckResult = await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads); stalledCheckResult = await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads);
} }
@@ -125,7 +132,7 @@ public sealed class QueueCleaner : GenericHandler
} }
} }
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient, deleteReason);
await _notifier.NotifyQueueItemDeleted(removeFromClient, deleteReason); await _notifier.NotifyQueueItemDeleted(removeFromClient, deleteReason);
} }
}); });
+230 -179
View File
@@ -1,52 +1,52 @@
## Table of contents ## Table of contents
- [General settings](variables.md#general-settings) - [General settings](#general-settings)
- [Queue Cleaner settings](variables.md#queue-cleaner-settings) - [Queue Cleaner settings](#queue-cleaner-settings)
- [Content Blocker settings](variables.md#content-blocker-settings) - [Content Blocker settings](#content-blocker-settings)
- [Download Cleaner settings](variables.md#download-cleaner-settings) - [Download Cleaner settings](#download-cleaner-settings)
- [Download Client settings](variables.md#download-client-settings) - [Download Client settings](#download-client-settings)
- [Arr settings](variables.md#arr-settings) - [Arr settings](#arr-settings)
- [Notification settings](variables.md#notification-settings) - [Notification settings](#notification-settings)
- [Advanced settings](variables.md#advanced-settings) - [Advanced settings](#advanced-settings)
# #
### General settings ### General settings
**`TZ`** #### **`TZ`**
- The time zone to use. - The time zone to use.
- Type: String. - Type: String.
- Possible values: Any valid timezone. - Possible values: Any valid timezone.
- Default: `UTC`. - Default: `UTC`.
- Required: No. - Required: No.
**`DRY_RUN`** #### **`DRY_RUN`**
- When enabled, simulates irreversible operations (like deletions and notifications) without making actual changes. - When enabled, simulates irreversible operations (like deletions and notifications) without making actual changes.
- Type: Boolean. - Type: Boolean.
- Possible values: `true`, `false`. - Possible values: `true`, `false`.
- Default: `false`. - Default: `false`.
- Required: No. - Required: No.
**`LOGGING__LOGLEVEL`** #### **`LOGGING__LOGLEVEL`**
- Controls the detail level of application logs. - Controls the detail level of application logs.
- Type: String. - Type: String.
- Possible values: `Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`. - Possible values: `Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`.
- Default: `Information`. - Default: `Information`.
- Required: No. - Required: No.
**`LOGGING__FILE__ENABLED`** #### **`LOGGING__FILE__ENABLED`**
- Enables logging to a file. - Enables logging to a file.
- Type: Boolean. - Type: Boolean.
- Possible values: `true`, `false`. - Possible values: `true`, `false`.
- Default: `false`. - Default: `false`.
- Required: No. - Required: No.
**`LOGGING__FILE__PATH`** #### **`LOGGING__FILE__PATH`**
- Directory where log files will be saved. - Directory where log files will be saved.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`LOGGING__ENHANCED`** #### **`LOGGING__ENHANCED`**
- Provides more detailed descriptions in logs whenever possible. - Provides more detailed descriptions in logs whenever possible.
- Type: Boolean. - Type: Boolean.
- Possible values: `true`, `false`. - Possible values: `true`, `false`.
@@ -58,7 +58,7 @@
### Queue Cleaner settings ### Queue Cleaner settings
**`TRIGGERS__QUEUECLEANER`** #### **`TRIGGERS__QUEUECLEANER`**
- Cron schedule for the queue cleaner job. - Cron schedule for the queue cleaner job.
- Type: String - [Quartz cron format](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html). - Type: String - [Quartz cron format](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).
- Default: `0 0/5 * * * ?` (every 5 minutes). - Default: `0 0/5 * * * ?` (every 5 minutes).
@@ -66,9 +66,9 @@
> [!NOTE] > [!NOTE]
> - Maximum interval is 6 hours. > - Maximum interval is 6 hours.
> - Ignored if `QUEUECLEANER__RUNSEQUENTIALLY=true` and `CONTENTBLOCKER__ENABLED=true`. > - Is ignored if `QUEUECLEANER__RUNSEQUENTIALLY=true` and `CONTENTBLOCKER__ENABLED=true`.
**`QUEUECLEANER__ENABLED`** #### **`QUEUECLEANER__ENABLED`**
- Enables or disables the queue cleaning functionality. - Enables or disables the queue cleaning functionality.
- When enabled, processes all items in the *arr queue. - When enabled, processes all items in the *arr queue.
- Type: Boolean - Type: Boolean
@@ -76,7 +76,7 @@
- Default: `true` - Default: `true`
- Required: No. - Required: No.
**`QUEUECLEANER__IGNORED_DOWNLOADS_PATH`** #### **`QUEUECLEANER__IGNORED_DOWNLOADS_PATH`**
- Local path to the file containing ignored downloads. - Local path to the file containing ignored downloads.
- If the contents of the file are changed, they will be reloaded on the next job run. - If the contents of the file are changed, they will be reloaded on the next job run.
- Accepted values: - Accepted values:
@@ -101,7 +101,7 @@
>[!IMPORTANT] >[!IMPORTANT]
> Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr. > Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr.
**`QUEUECLEANER__RUNSEQUENTIALLY`** #### **`QUEUECLEANER__RUNSEQUENTIALLY`**
- Controls whether queue cleaner runs after content blocker instead of in parallel. - Controls whether queue cleaner runs after content blocker instead of in parallel.
- When `true`, streamlines the cleaning process by running immediately after content blocker. - When `true`, streamlines the cleaning process by running immediately after content blocker.
- Type: Boolean - Type: Boolean
@@ -109,23 +109,24 @@
- Default: `true` - Default: `true`
- Required: No. - Required: No.
**`QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES`** #### **`QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES`**
- Number of strikes before removing a failed import. - Number of strikes before removing a failed import.
- Set to `0` to never remove failed imports. - Set to `0` to never remove failed imports.
- A strike is given when an item is stalled, stuck in metadata downloading, or failed to be imported. - A strike is given when an item fails to be imported.
- Type: Integer - Type: Integer
- Possible values: `0` or greater
- Default: `0` - Default: `0`
- Required: No. - Required: No.
> [!NOTE]
> If not set to `0`, the minimum value is `3`.
**`QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE`** #### **`QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE`**
- Controls whether to ignore failed imports from private trackers. - Controls whether to ignore failed imports from private trackers.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE`** #### **`QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE`**
- Controls whether to delete failed imports from private trackers from the download client. - Controls whether to delete failed imports from private trackers from the download client.
- Has no effect if `QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE` is `true`. - Has no effect if `QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE` is `true`.
- Type: Boolean - Type: Boolean
@@ -134,44 +135,45 @@
- Required: No. - Required: No.
> [!WARNING] > [!WARNING]
> Setting `QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your tracker account. > Setting `QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
**`QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS`** #### **`QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS`**
- Patterns to look for in failed import messages that should be ignored. - Patterns to look for in failed import messages that should be ignored.
- Multiple patterns can be specified using incrementing numbers starting from 0. - Multiple patterns can be specified using incrementing numbers starting from 0.
- Type: String array - Type: String array
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
- Example: - Example:
```yaml ```yaml
QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0: "title mismatch" QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0: "title mismatch"
QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required" QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
``` ```
**`QUEUECLEANER__STALLED_MAX_STRIKES`** #### **`QUEUECLEANER__STALLED_MAX_STRIKES`**
- Number of strikes before removing a stalled download. - Number of strikes before removing a stalled download.
- Set to `0` to never remove stalled downloads. - Set to `0` to never remove stalled downloads.
- A strike is given when download speed is 0. - A strike is given when an item is stalled (not downloading) or stuck while downloading metadata.
- Type: Integer - Type: Integer
- Possible values: `0` or greater
- Default: `0` - Default: `0`
- Required: No. - Required: No.
> [!NOTE]
> If not set to `0`, the minimum value is `3`.
**`QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS`** #### **`QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS`**
- Controls whether to remove strikes if any download progress was made since last checked. - Controls whether to remove strikes if any download progress was made since last checked.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`QUEUECLEANER__STALLED_IGNORE_PRIVATE`** #### **`QUEUECLEANER__STALLED_IGNORE_PRIVATE`**
- Controls whether to ignore stalled downloads from private trackers. - Controls whether to ignore stalled downloads from private trackers.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`QUEUECLEANER__STALLED_DELETE_PRIVATE`** #### **`QUEUECLEANER__STALLED_DELETE_PRIVATE`**
- Controls whether to delete stalled private downloads from the download client. - Controls whether to delete stalled private downloads from the download client.
- Has no effect if `QUEUECLEANER__STALLED_IGNORE_PRIVATE` is `true`. - Has no effect if `QUEUECLEANER__STALLED_IGNORE_PRIVATE` is `true`.
- Type: Boolean - Type: Boolean
@@ -180,13 +182,13 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Required: No. - Required: No.
> [!WARNING] > [!WARNING]
> Setting `QUEUECLEANER__STALLED_DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your tracker account. > Setting `QUEUECLEANER__STALLED_DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
# #
### Content Blocker settings ### Content Blocker settings
**`TRIGGERS__CONTENTBLOCKER`** #### **`TRIGGERS__CONTENTBLOCKER`**
- Cron schedule for the content blocker job. - Cron schedule for the content blocker job.
- Type: String - [Quartz cron format](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html). - Type: String - [Quartz cron format](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).
- Default: `0 0/5 * * * ?` (every 5 minutes). - Default: `0 0/5 * * * ?` (every 5 minutes).
@@ -195,7 +197,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
> [!NOTE] > [!NOTE]
> - Maximum interval is 6 hours. > - Maximum interval is 6 hours.
**`CONTENTBLOCKER__ENABLED`** #### **`CONTENTBLOCKER__ENABLED`**
- Enables or disables the content blocker functionality. - Enables or disables the content blocker functionality.
- When enabled, processes all items in the *arr queue and marks unwanted files. - When enabled, processes all items in the *arr queue and marks unwanted files.
- Type: Boolean - Type: Boolean
@@ -203,7 +205,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH`** #### **`CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH`**
- Local path to the file containing ignored downloads. - Local path to the file containing ignored downloads.
- If the contents of the file are changed, they will be reloaded on the next job run. - If the contents of the file are changed, they will be reloaded on the next job run.
- Accepted values: - Accepted values:
@@ -228,14 +230,14 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
>[!IMPORTANT] >[!IMPORTANT]
> Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr. > Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr.
**`CONTENTBLOCKER__IGNORE_PRIVATE`** #### **`CONTENTBLOCKER__IGNORE_PRIVATE`**
- Controls whether to ignore downloads from private trackers. - Controls whether to ignore downloads from private trackers.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`CONTENTBLOCKER__DELETE_PRIVATE`** #### **`CONTENTBLOCKER__DELETE_PRIVATE`**
- Controls whether to delete private downloads that have all files blocked from the download client. - Controls whether to delete private downloads that have all files blocked from the download client.
- Has no effect if `CONTENTBLOCKER__IGNORE_PRIVATE` is `true`. - Has no effect if `CONTENTBLOCKER__IGNORE_PRIVATE` is `true`.
- Type: Boolean - Type: Boolean
@@ -244,13 +246,13 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Required: No. - Required: No.
> [!WARNING] > [!WARNING]
> Setting `CONTENTBLOCKER__DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your tracker account. > Setting `CONTENTBLOCKER__DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
# #
### Download Cleaner settings ### Download Cleaner settings
**`TRIGGERS__DOWNLOADCLEANER`** #### **`TRIGGERS__DOWNLOADCLEANER`**
- Cron schedule for the download cleaner job. - Cron schedule for the download cleaner job.
- Type: String - [Quartz cron format](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html). - Type: String - [Quartz cron format](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).
- Default: `0 0 * * * ?` (every hour). - Default: `0 0 * * * ?` (every hour).
@@ -259,7 +261,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
> [!NOTE] > [!NOTE]
> - Maximum interval is 6 hours. > - Maximum interval is 6 hours.
**`DOWNLOADCLEANER__ENABLED`** #### **`DOWNLOADCLEANER__ENABLED`**
- Enables or disables the download cleaner functionality. - Enables or disables the download cleaner functionality.
- When enabled, automatically cleans up downloads that have been seeding for a certain amount of time. - When enabled, automatically cleans up downloads that have been seeding for a certain amount of time.
- Type: Boolean. - Type: Boolean.
@@ -267,7 +269,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH`** #### **`DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH`**
- Local path to the file containing ignored downloads. - Local path to the file containing ignored downloads.
- If the contents of the file are changed, they will be reloaded on the next job run. - If the contents of the file are changed, they will be reloaded on the next job run.
- Accepted values: - Accepted values:
@@ -292,7 +294,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
>[!IMPORTANT] >[!IMPORTANT]
> Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr. > Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr.
**`DOWNLOADCLEANER__DELETE_PRIVATE`** #### **`DOWNLOADCLEANER__DELETE_PRIVATE`**
- Controls whether to delete private downloads. - Controls whether to delete private downloads.
- Type: Boolean. - Type: Boolean.
- Possible values: `true`, `false` - Possible values: `true`, `false`
@@ -300,9 +302,9 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Required: No. - Required: No.
> [!WARNING] > [!WARNING]
> Setting `DOWNLOADCLEANER__DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your tracker account. > Setting `DOWNLOADCLEANER__DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
**`DOWNLOADCLEANER__CATEGORIES__0__NAME`** #### **`DOWNLOADCLEANER__CATEGORIES__0__NAME`**
- Name of the category to clean. - Name of the category to clean.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
@@ -310,18 +312,21 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
> [!NOTE] > [!NOTE]
> The category name must match the category that was set in the *arr. > The category name must match the category that was set in the *arr.
>
> For qBittorrent, the category name is the name of the download category. > For qBittorrent, the category name is the name of the download category.
>
> For Deluge, the category name is the name of the label. > For Deluge, the category name is the name of the label.
>
> For Transmission, the category name is the last directory from the save location. > For Transmission, the category name is the last directory from the save location.
**`DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO`** #### **`DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO`**
- Maximum ratio to reach before removing a download. - Maximum ratio to reach before removing a download.
- Type: Decimal. - Type: Decimal.
- Possible values: `-1` or greater (`-1` means no limit or disabled). - Possible values: `-1` or greater (`-1` means no limit or disabled).
- Default: `-1` - Default: `-1`
- Required: No. - Required: No.
**`DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME`** #### **`DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME`**
- Minimum number of hours to seed before removing a download, if the ratio has been met. - Minimum number of hours to seed before removing a download, if the ratio has been met.
- Used with `MAX_RATIO` to ensure a minimum seed time. - Used with `MAX_RATIO` to ensure a minimum seed time.
- Type: Decimal. - Type: Decimal.
@@ -329,7 +334,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Default: `0` - Default: `0`
- Required: No. - Required: No.
**`DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME`** #### **`DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME`**
- Maximum number of hours to seed before removing a download. - Maximum number of hours to seed before removing a download.
- Type: Decimal. - Type: Decimal.
- Possible values: `-1` or greater (`-1` means no limit or disabled). - Possible values: `-1` or greater (`-1` means no limit or disabled).
@@ -352,59 +357,82 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
### Download Client settings ### Download Client settings
**`DOWNLOAD_CLIENT`** #### **`DOWNLOAD_CLIENT`**
- Specifies which download client is used by *arrs. - Specifies which download client is used by *arrs.
- Type: String. - Type: String.
- Possible values: `none`, `qbittorrent`, `deluge`, `transmission`. - Possible values: `none`, `qbittorrent`, `deluge`, `transmission`, `disabled`.
- Default: `none` - Default: `none`
- Required: No. - Required: No.
> [!NOTE] > [!NOTE]
> 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. > 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.
**`QBITTORRENT__URL`** > [!IMPORTANT]
> When the download client is set to `disabled`, the queue cleaner will be able to remove items that are failed to be imported even if there is no download client configured. This means that all downloads, including private ones, will be completely removed.
>
> Setting `DOWNLOAD_CLIENT=disabled` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
#### **`QBITTORRENT__URL`**
- URL of the qBittorrent instance. - URL of the qBittorrent instance.
- Type: String. - Type: String.
- Default: `http://localhost:8080`. - Default: `http://localhost:8080`.
- Required: No. - Required: No.
**`QBITTORRENT__USERNAME`** #### **`QBITTORRENT__URL_BASE`**
- Adds a prefix to the qBittorrent url, such as `[QBITTORRENT__URL]/[QBITTORRENT__URL_BASE]/api`.
- Type: String.
- Default: Empty.
- Required: No.
#### **`QBITTORRENT__USERNAME`**
- Username for qBittorrent authentication. - Username for qBittorrent authentication.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`QBITTORRENT__PASSWORD`** #### **`QBITTORRENT__PASSWORD`**
- Password for qBittorrent authentication. - Password for qBittorrent authentication.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`DELUGE__URL`** #### **`DELUGE__URL`**
- URL of the Deluge instance. - URL of the Deluge instance.
- Type: String. - Type: String.
- Default: `http://localhost:8112`. - Default: `http://localhost:8112`.
- Required: No. - Required: No.
**`DELUGE__PASSWORD`** #### **`DELUGE__URL_BASE`**
- Adds a prefix to the deluge json url, such as `[DELUGE__URL]/[DELUGE__URL_BASE]/json`.
- Type: String.
- Default: Empty.
- Required: No.
#### **`DELUGE__PASSWORD`**
- Password for Deluge authentication. - Password for Deluge authentication.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`TRANSMISSION__URL`** #### **`TRANSMISSION__URL`**
- URL of the Transmission instance. - URL of the Transmission instance.
- Type: String. - Type: String.
- Default: `http://localhost:9091`. - Default: `http://localhost:9091`.
- Required: No. - Required: No.
**`TRANSMISSION__USERNAME`** #### **`TRANSMISSION__URL_BASE`**
- Adds a prefix to the Transmission rpc url, such as `[TRANSMISSION__URL]/[TRANSMISSION__URL_BASE]/rpc`.
- Type: String.
- Default: `transmission`.
- Required: No.
#### **`TRANSMISSION__USERNAME`**
- Username for Transmission authentication. - Username for Transmission authentication.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`TRANSMISSION__PASSWORD`** #### **`TRANSMISSION__PASSWORD`**
- Password for Transmission authentication. - Password for Transmission authentication.
- Type: String. - Type: String.
- Default: Empty. - Default: Empty.
@@ -414,112 +442,6 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
### Arr settings ### Arr settings
**`SONARR__ENABLED`**
- Enables or disables Sonarr cleanup.
- Type: Boolean
- Possible values: `true`, `false`
- Default: `false`
- Required: No.
**`SONARR__BLOCK__TYPE`**
- Determines how file blocking works for Sonarr.
- Type: String
- Possible values: `blacklist`, `whitelist`
- Default: `blacklist`
- Required: No.
**`SONARR__BLOCK__PATH`**
- Path to the blocklist file (local file or URL).
- Must be JSON compatible.
- Type: String
- Default: Empty.
- Required: No.
**`SONARR__SEARCHTYPE`**
- Determines what to search for after removing a queue item.
- Type: String
- Possible values: `Episode`, `Season`, `Series`
- Default: `Episode`
- Required: No.
**`SONARR__INSTANCES__0__URL`**
- URL of the Sonarr instance.
- Type: String
- Default: `http://localhost:8989`
- Required: No.
**`SONARR__INSTANCES__0__APIKEY`**
- API key for the Sonarr instance.
- Type: String
- Default: Empty.
- Required: No.
**`RADARR__ENABLED`**
- Enables or disables Radarr cleanup.
- Type: Boolean
- Possible values: `true`, `false`
- Default: `false`
- Required: No.
**`RADARR__BLOCK__TYPE`**
- Determines how file blocking works for Radarr.
- Type: String
- Possible values: `blacklist`, `whitelist`
- Default: `blacklist`
- Required: No.
**`RADARR__BLOCK__PATH`**
- Path to the blocklist file (local file or URL).
- Must be JSON compatible.
- Type: String
- Default: Empty.
- Required: No.
**`RADARR__INSTANCES__0__URL`**
- URL of the Radarr instance.
- Type: String
- Default: `http://localhost:7878`
- Required: No.
**`RADARR__INSTANCES__0__APIKEY`**
- API key for the Radarr instance.
- Type: String
- Default: Empty.
- Required: No.
**`LIDARR__ENABLED`**
- Enables or disables Lidarr cleanup.
- Type: Boolean
- Possible values: `true`, `false`
- Default: `false`
- Required: No.
**`LIDARR__BLOCK__TYPE`**
- Determines how file blocking works for Lidarr.
- Type: String
- Possible values: `blacklist`, `whitelist`
- Default: `blacklist`
- Required: No.
**`LIDARR__BLOCK__PATH`**
- Path to the blocklist file (local file or URL).
- Must be JSON compatible.
- Type: String
- Default: Empty.
- Required: No.
**`LIDARR__INSTANCES__0__URL`**
- URL of the Lidarr instance.
- Type: String
- Default: `http://localhost:8686`
- Required: No.
**`LIDARR__INSTANCES__0__APIKEY`**
- API key for the Lidarr instance.
- Type: String
- Default: Empty.
- Required: No.
> [!NOTE] > [!NOTE]
> Multiple instances can be specified for each *arr using this format, where `<NUMBER>` starts from 0: > Multiple instances can be specified for each *arr using this format, where `<NUMBER>` starts from 0:
> ```yaml > ```yaml
@@ -527,8 +449,29 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
> <ARR>__INSTANCES__<NUMBER>__APIKEY > <ARR>__INSTANCES__<NUMBER>__APIKEY
> ``` > ```
#### **`SONARR__ENABLED`**
- Enables or disables Sonarr cleanup.
- Type: Boolean
- Possible values: `true`, `false`
- Default: `false`
- Required: No.
#### **`SONARR__BLOCK__TYPE`**
- Determines how file blocking works for Sonarr.
- Type: String
- Possible values: `blacklist`, `whitelist`
- Default: `blacklist`
- Required: No.
#### **`SONARR__BLOCK__PATH`**
- Path to the blocklist file (local file or URL).
- Must be JSON compatible.
- Type: String
- Default: Empty.
- Required: No.
> [!NOTE] > [!NOTE]
> The blocklists (blacklist/whitelist) support the following patterns: > The blocklists support the following patterns:
> ``` > ```
> *example // file name ends with "example" > *example // file name ends with "example"
> example* // file name starts with "example" > example* // file name starts with "example"
@@ -538,47 +481,155 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
> ``` > ```
> [!NOTE] > [!NOTE]
> [This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr, but they are not suitable for other *arrs. > [This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr.
#### **`SONARR__SEARCHTYPE`**
- Determines what to search for after removing a queue item.
- Type: String
- Possible values: `Episode`, `Season`, `Series`
- Default: `Episode`
- Required: No.
#### **`SONARR__INSTANCES__0__URL`**
- URL of the Sonarr instance.
- Type: String
- Default: `http://localhost:8989`
- Required: No.
#### **`SONARR__INSTANCES__0__APIKEY`**
- API key for the Sonarr instance.
- Type: String
- Default: Empty.
- Required: No.
#### **`RADARR__ENABLED`**
- Enables or disables Radarr cleanup.
- Type: Boolean
- Possible values: `true`, `false`
- Default: `false`
- Required: No.
#### **`RADARR__BLOCK__TYPE`**
- Determines how file blocking works for Radarr.
- Type: String
- Possible values: `blacklist`, `whitelist`
- Default: `blacklist`
- Required: No.
#### **`RADARR__BLOCK__PATH`**
- Path to the blocklist file (local file or URL).
- Must be JSON compatible.
- Type: String
- Default: Empty.
- Required: No.
> [!NOTE]
> The blocklists support the following patterns:
> ```
> *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:"
> ```
> [!NOTE]
> [This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr.
#### **`RADARR__INSTANCES__0__URL`**
- URL of the Radarr instance.
- Type: String
- Default: `http://localhost:7878`
- Required: No.
#### **`RADARR__INSTANCES__0__APIKEY`**
- API key for the Radarr instance.
- Type: String
- Default: Empty.
- Required: No.
#### **`LIDARR__ENABLED`**
- Enables or disables Lidarr cleanup.
- Type: Boolean
- Possible values: `true`, `false`
- Default: `false`
- Required: No.
#### **`LIDARR__BLOCK__TYPE`**
- Determines how file blocking works for Lidarr.
- Type: String
- Possible values: `blacklist`, `whitelist`
- Default: `blacklist`
- Required: No.
#### **`LIDARR__BLOCK__PATH`**
- Path to the blocklist file (local file or URL).
- Must be JSON compatible.
- Type: String
- Default: Empty.
- Required: No.
> [!NOTE]
> The blocklists support the following patterns:
> ```
> *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:"
> ```
#### **`LIDARR__INSTANCES__0__URL`**
- URL of the Lidarr instance.
- Type: String
- Default: `http://localhost:8686`
- Required: No.
#### **`LIDARR__INSTANCES__0__APIKEY`**
- API key for the Lidarr instance.
- Type: String
- Default: Empty.
- Required: No.
# #
### Notification settings ### Notification settings
**`NOTIFIARR__API_KEY`** #### **`NOTIFIARR__API_KEY`**
- Notifiarr API key for sending notifications. - Notifiarr API key for sending notifications.
- Requires Notifiarr's [`Passthrough`](https://notifiarr.wiki/en/Website/Integrations/Passthrough) integration to work. - Requires Notifiarr's [`Passthrough`](https://notifiarr.wiki/en/Website/Integrations/Passthrough) integration to work.
- Type: String - Type: String
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`NOTIFIARR__CHANNEL_ID`** #### **`NOTIFIARR__CHANNEL_ID`**
- Discord channel ID where notifications will be sent. - Discord channel ID where notifications will be sent.
- Type: String - Type: String
- Default: Empty. - Default: Empty.
- Required: No. - Required: No.
**`NOTIFIARR__ON_IMPORT_FAILED_STRIKE`** #### **`NOTIFIARR__ON_IMPORT_FAILED_STRIKE`**
- Controls whether to notify when an item receives a failed import strike. - Controls whether to notify when an item receives a failed import strike.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`NOTIFIARR__ON_STALLED_STRIKE`** #### **`NOTIFIARR__ON_STALLED_STRIKE`**
- Controls whether to notify when an item receives a stalled download strike. - Controls whether to notify when an item receives a stalled download strike.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`NOTIFIARR__ON_QUEUE_ITEM_DELETED`** #### **`NOTIFIARR__ON_QUEUE_ITEM_DELETED`**
- Controls whether to notify when a queue item is deleted. - Controls whether to notify when a queue item is deleted.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
- Default: `false` - Default: `false`
- Required: No. - Required: No.
**`NOTIFIARR__ON_DOWNLOAD_CLEANED`** #### **`NOTIFIARR__ON_DOWNLOAD_CLEANED`**
- Controls whether to notify when a download is cleaned. - Controls whether to notify when a download is cleaned.
- Type: Boolean - Type: Boolean
- Possible values: `true`, `false` - Possible values: `true`, `false`
@@ -589,7 +640,7 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
### Advanced settings ### Advanced settings
**`HTTP_MAX_RETRIES`** #### **`HTTP_MAX_RETRIES`**
- The number of times to retry a failed HTTP call. - The number of times to retry a failed HTTP call.
- Applies to calls to *arrs, download clients, and other services. - Applies to calls to *arrs, download clients, and other services.
- Type: Integer - Type: Integer
@@ -597,10 +648,10 @@ QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1: "manual import required"
- Default: `0` - Default: `0`
- Required: No. - Required: No.
**`HTTP_TIMEOUT`** #### **`HTTP_TIMEOUT`**
- The number of seconds to wait before failing an HTTP call. - The number of seconds to wait before failing an HTTP call.
- Applies to calls to *arrs, download clients, and other services. - Applies to calls to *arrs, download clients, and other services.
- Type: Integer - Type: Integer
- Possible values: Greater than `0` - Possible values: Greater than `0`.
- Default: `100` - Default: `100`
- Required: No. - Required: No.