Compare commits

..

12 Commits

Author SHA1 Message Date
Flaminel
baf6a8c2f4 Add option to set failed import strikes per arr (#135) 2025-05-09 01:17:16 +03:00
Flaminel
cd345afc54 Fix logs when using qBit tag instead of category (#134) 2025-05-08 22:50:14 +03:00
Flaminel
246ec4d6eb Add option to set a tag instead of changing the category for unlinked downloads (#133) 2025-05-08 21:51:08 +03:00
Flaminel
569eeae181 Fix hardlinks on ARM64 (#130) 2025-05-07 21:44:49 +03:00
Flaminel
5a0ef56074 Remove empty clean categories list validation (#131) 2025-05-07 14:12:36 +03:00
Flaminel
09bd4321fb fixed readme icons 2025-05-06 19:59:00 +03:00
Flaminel
4939e37210 updated discord invite 2025-05-06 15:57:12 +03:00
Flaminel
9463d7587f Add support for unstrusted certificates (#128) 2025-05-06 15:42:41 +03:00
Flaminel
7d2bf41bec updated readme to mention Huntarr 2025-05-06 15:35:37 +03:00
Flaminel
93bb8cc18d updated README 2025-05-05 12:25:22 +03:00
Flaminel
449d9e623f fixed missing config variables 2025-05-05 12:25:09 +03:00
Flaminel
3a50d9be3c updated docs 2025-05-05 00:35:10 +03:00
36 changed files with 432 additions and 52 deletions

View File

@@ -4,9 +4,11 @@ on:
jobs:
build:
uses: flmorg/universal-workflows/.github/workflows/dotnet.build.app.yml@main
uses: flmorg/universal-workflows-testing/.github/workflows/dotnet.build.app.yml@main
with:
dockerRepository: flaminel/cleanuperr
githubContext: ${{ toJSON(github) }}
outputName: cleanuperr
selfContained: false
baseImage: 9.0-bookworm-slim
secrets: inherit

View File

@@ -2,7 +2,7 @@ _Love this project? Give it a ⭐️ and let others know!_
# <img width="24px" src="./Logo/256.png" alt="cleanuperr"></img> Cleanuperr
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/sWggpnmGNY)
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/SCtMCgtsc4)
Cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuperr can also trigger a search to replace the deleted shows/movies.
@@ -15,17 +15,37 @@ Cleanuperr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads that have a low download speed or high estimated completion time.
> - Remove downloads blocked by qBittorrent or by Cleanuperr's **content blocker**.
> - Trigger a search for downloads removed from the *arrs.
> - Remove downloads that have been seeding for a certain amount of time.
> - Remove downloads that have no hardlinks (have been upgraded by the *arrs).
> - Clean up downloads that have been seeding for a certain amount of time.
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuperr.
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.
## Quick Start
> [!NOTE]
>
> 1. **Docker (Recommended)**
> Pull the Docker image from `ghcr.io/flmorg/cleanuperr:latest`.
>
> 2. **Unraid (for Unraid users)**
> Use the Unraid Community App.
>
> 3. **Manual Installation (if you're not using Docker)**
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
# Docs
Docs can be found [here](https://flmorg.github.io/cleanuperr/).
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuperr"> <span style="vertical-align: middle;">Cleanuperr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
Think of **Cleanuperr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuperr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuperr** and **Huntarr** will take your automated media stack to another level.
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</span></span>
# Credits
Special thanks for inspiration go to:
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)

View File

@@ -1,4 +1,5 @@
using Common.Configuration.ContentBlocker;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.Arr;
@@ -7,6 +8,9 @@ public abstract record ArrConfig
public required bool Enabled { get; init; }
public Block Block { get; init; } = new();
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
public short ImportFailedMaxStrikes { get; init; } = -1;
public required List<ArrInstance> Instances { get; init; }
}

View File

@@ -20,6 +20,9 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
[ConfigurationKeyName("UNLINKED_TARGET_CATEGORY")]
public string UnlinkedTargetCategory { get; init; } = "cleanuperr-unlinked";
[ConfigurationKeyName("UNLINKED_USE_TAG")]
public bool UnlinkedUseTag { get; init; }
[ConfigurationKeyName("UNLINKED_IGNORED_ROOT_DIR")]
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
@@ -33,11 +36,6 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
return;
}
if (Categories?.Count is null or 0)
{
throw new ValidationException("no categories configured");
}
if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true)
{
throw new ValidationException("duplicated clean categories found");

View File

@@ -1,4 +1,5 @@
using Common.Exceptions;
using Common.Enums;
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.General;
@@ -10,6 +11,9 @@ public sealed record HttpConfig : IConfig
[ConfigurationKeyName("HTTP_TIMEOUT")]
public ushort Timeout { get; init; } = 100;
[ConfigurationKeyName("HTTP_VALIDATE_CERT")]
public CertificateValidationType CertificateValidation { get; init; } = CertificateValidationType.Enabled;
public void Validate()
{

View File

@@ -0,0 +1,8 @@
namespace Common.Enums;
public enum CertificateValidationType
{
Enabled = 0,
DisabledForLocalAddresses = 1,
Disabled = 2
}

View File

@@ -1,10 +1,12 @@
using System.Net;
using Common.Configuration.General;
using Common.Helpers;
using Infrastructure.Services;
using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.Notifications.Consumers;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
@@ -62,6 +64,15 @@ public static class MainDI
{
x.Timeout = TimeSpan.FromSeconds(config.Timeout);
})
.ConfigurePrimaryHttpMessageHandler(provider =>
{
CertificateValidationService service = provider.GetRequiredService<CertificateValidationService>();
return new HttpClientHandler
{
ServerCertificateCustomValidationCallback = service.ShouldByPassValidationError
};
})
.AddRetryPolicyHandler(config);
// add Deluge HttpClient

View File

@@ -3,6 +3,7 @@ using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Providers;
using Infrastructure.Services;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
@@ -21,6 +22,7 @@ public static class ServicesDI
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<CertificateValidationService>()
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()

View File

@@ -1,7 +1,8 @@
{
"DRY_RUN": true,
"HTTP_MAX_RETRIES": 0,
"HTTP_TIMEOUT": 10,
"HTTP_TIMEOUT": 100,
"HTTP_VALIDATE_CERT": "enabled",
"Logging": {
"LogLevel": "Verbose",
"Enhanced": true,
@@ -56,6 +57,7 @@
}
],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"UNLINKED_USE_TAG": false,
"UNLINKED_IGNORED_ROOT_DIR": "",
"UNLINKED_CATEGORIES": [
"tv-sonarr",
@@ -83,6 +85,7 @@
},
"Sonarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
@@ -97,6 +100,7 @@
},
"Radarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
@@ -110,6 +114,7 @@
},
"Lidarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"

View File

@@ -2,6 +2,7 @@
"DRY_RUN": false,
"HTTP_MAX_RETRIES": 0,
"HTTP_TIMEOUT": 100,
"HTTP_VALIDATE_CERT": "enabled",
"Logging": {
"LogLevel": "Information",
"Enhanced": true,
@@ -21,7 +22,7 @@
"IGNORED_DOWNLOADS_PATH": ""
},
"QueueCleaner": {
"Enabled": true,
"Enabled": false,
"RunSequentially": true,
"IGNORED_DOWNLOADS_PATH": "",
"IMPORT_FAILED_MAX_STRIKES": 0,
@@ -32,13 +33,21 @@
"STALLED_RESET_STRIKES_ON_PROGRESS": false,
"STALLED_IGNORE_PRIVATE": false,
"STALLED_DELETE_PRIVATE": false,
"DOWNLOADING_METADATA_MAX_STRIKES": 0
"DOWNLOADING_METADATA_MAX_STRIKES": 0,
"SLOW_MAX_STRIKES": 0,
"SLOW_RESET_STRIKES_ON_PROGRESS": true,
"SLOW_IGNORE_PRIVATE": false,
"SLOW_DELETE_PRIVATE": false,
"SLOW_MIN_SPEED": "",
"SLOW_MAX_TIME": 0,
"SLOW_IGNORE_ABOVE_SIZE": ""
},
"DownloadCleaner": {
"Enabled": false,
"DELETE_PRIVATE": false,
"CATEGORIES": [],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"UNLINKED_USE_TAG": false,
"UNLINKED_IGNORED_ROOT_DIR": "",
"UNLINKED_CATEGORIES": [],
"IGNORED_DOWNLOADS_PATH": ""
@@ -63,6 +72,7 @@
},
"Sonarr": {
"Enabled": false,
"IMPORT_FAILED_MAX_STRIKES": -1,
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
@@ -77,6 +87,7 @@
},
"Radarr": {
"Enabled": false,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": ""
@@ -90,6 +101,7 @@
},
"Lidarr": {
"Enabled": false,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": ""

View File

@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Sockets;
namespace Infrastructure.Extensions;
public static class IpAddressExtensions
{
public static bool IsLocalAddress(this IPAddress ipAddress)
{
// Map back to IPv4 if mapped to IPv6, for example "::ffff:1.2.3.4" to "1.2.3.4".
if (ipAddress.IsIPv4MappedToIPv6)
{
ipAddress = ipAddress.MapToIPv4();
}
// Checks loopback ranges for both IPv4 and IPv6.
if (IPAddress.IsLoopback(ipAddress))
{
return true;
}
// IPv4
if (ipAddress.AddressFamily == AddressFamily.InterNetwork)
{
return IsLocalIPv4(ipAddress.GetAddressBytes());
}
// IPv6
if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
{
return ipAddress.IsIPv6LinkLocal ||
ipAddress.IsIPv6UniqueLocal ||
ipAddress.IsIPv6SiteLocal;
}
return false;
}
private static bool IsLocalIPv4(byte[] ipv4Bytes)
{
// Link local (no IP assigned by DHCP): 169.254.0.0 to 169.254.255.255 (169.254.0.0/16)
bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
// Class A private range: 10.0.0.0 10.255.255.255 (10.0.0.0/8)
bool IsClassA() => ipv4Bytes[0] == 10;
// Class B private range: 172.16.0.0 172.31.255.255 (172.16.0.0/12)
bool IsClassB() => ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
// Class C private range: 192.168.0.0 192.168.255.255 (192.168.0.0/16)
bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB();
}
}

View File

@@ -18,7 +18,7 @@
<PackageReference Include="MassTransit" Version="8.3.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Scrutor" Version="6.0.1" />
</ItemGroup>

View File

@@ -0,0 +1,86 @@
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Common.Configuration.General;
using Common.Enums;
using Infrastructure.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Services;
public class CertificateValidationService
{
private readonly ILogger<CertificateValidationService> _logger;
private readonly HttpConfig _config;
public CertificateValidationService(ILogger<CertificateValidationService> logger, IOptions<HttpConfig> config)
{
_logger = logger;
_config = config.Value;
}
public bool ShouldByPassValidationError(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
{
var targetHostName = string.Empty;
if (sender is not SslStream && sender is not string)
{
return true;
}
if (sender is SslStream request)
{
targetHostName = request.TargetHostName;
}
// Mailkit passes host in sender as string
if (sender is string stringHost)
{
targetHostName = stringHost;
}
if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA")
{
_logger.LogError(
$"https://{targetHostName} uses the obsolete md5 hash in its https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.");
}
if (sslPolicyErrors == SslPolicyErrors.None)
{
return true;
}
if (targetHostName is "localhost" or "127.0.0.1")
{
return true;
}
var ipAddresses = GetIpAddresses(targetHostName);
if (_config.CertificateValidation == CertificateValidationType.Disabled)
{
return true;
}
if (_config.CertificateValidation == CertificateValidationType.DisabledForLocalAddresses &&
ipAddresses.All(i => i.IsLocalAddress()))
{
return true;
}
_logger.LogError($"certificate validation for {targetHostName} failed. {sslPolicyErrors}");
return false;
}
private static IPAddress[] GetIpAddresses(string host)
{
if (IPAddress.TryParse(host, out var ipAddress))
{
return [ipAddress];
}
return Dns.GetHostEntry(host).AddressList;
}
}

View File

@@ -73,7 +73,7 @@ public abstract class ArrClient : IArrClient
return queueResponse;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload)
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
{
if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload)
{
@@ -102,11 +102,19 @@ public abstract class ArrClient : IArrClient
_logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
return false;
}
if (arrMaxStrikes is 0)
{
_logger.LogDebug("skip failed import check | arr max strikes is 0 | {name}", record.Title);
return false;
}
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : _queueCleanerConfig.ImportFailedMaxStrikes;
return await _striker.StrikeAndCheckLimit(
record.DownloadId,
record.Title,
_queueCleanerConfig.ImportFailedMaxStrikes,
maxStrikes,
StrikeType.ImportFailed
);
}

View File

@@ -9,7 +9,7 @@ public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);

View File

@@ -75,7 +75,7 @@ public sealed class ContentBlocker : GenericHandler
await base.ExecuteAsync();
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
{
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();

View File

@@ -58,9 +58,12 @@ public sealed class DownloadCleaner : GenericHandler
return;
}
if (_config.Categories?.Count is null or 0)
bool isUnlinkedEnabled = !string.IsNullOrEmpty(_config.UnlinkedTargetCategory) && _config.UnlinkedCategories?.Count > 0;
bool isCleaningEnabled = _config.Categories?.Count > 0;
if (!isUnlinkedEnabled && !isCleaningEnabled)
{
_logger.LogWarning("no categories configured");
_logger.LogWarning("{name} is not configured properly", nameof(DownloadCleaner));
return;
}
@@ -68,15 +71,27 @@ public sealed class DownloadCleaner : GenericHandler
await _downloadService.LoginAsync();
List<object>? downloads = await _downloadService.GetSeedingDownloads();
List<object>? downloadsToChangeCategory = null;
if (downloads?.Count is null or 0)
{
_logger.LogDebug("no seeding downloads found");
return;
}
if (!string.IsNullOrEmpty(_config.UnlinkedTargetCategory) && _config.UnlinkedCategories?.Count > 0)
_logger.LogTrace("found {count} seeding downloads", downloads.Count);
List<object>? downloadsToChangeCategory = null;
if (isUnlinkedEnabled)
{
if (!_hardLinkCategoryCreated)
{
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.QBittorrent && !_config.UnlinkedUseTag)
{
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
}
_hardLinkCategoryCreated = true;
}
@@ -89,20 +104,30 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr, true);
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true);
await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true);
if (isUnlinkedEnabled)
{
_logger.LogTrace("found {count} potential downloads to change category", downloadsToChangeCategory?.Count);
await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads);
_logger.LogTrace("finished changing category");
}
_logger.LogTrace("looking for downloads to change category");
await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads);
if (_config.Categories?.Count is null or 0)
{
return;
}
List<object>? downloadsToClean = _downloadService.FilterDownloadsToBeCleanedAsync(downloads, _config.Categories);
// release unused objects
downloads = null;
_logger.LogTrace("looking for downloads to clean");
_logger.LogTrace("found {count} potential downloads to clean", downloadsToClean?.Count);
await _downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads);
_logger.LogTrace("finished cleaning downloads");
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
{
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());

View File

@@ -251,6 +251,15 @@ public class QBitService : DownloadService, IQBitService
?.Cast<TorrentInfo>()
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x =>
{
if (_downloadCleanerConfig.UnlinkedUseTag)
{
return !x.Tags.Any(tag => tag.Equals(_downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase));
}
return true;
})
.Cast<object>()
.ToList();
@@ -436,12 +445,18 @@ public class QBitService : DownloadService, IQBitService
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
if (_downloadCleanerConfig.UnlinkedUseTag)
{
_logger.LogInformation("tag added for {name}", download.Name);
}
else
{
_logger.LogInformation("category changed for {name}", download.Name);
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
}
_logger.LogInformation("category changed for {name}", download.Name);
await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory);
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory, _downloadCleanerConfig.UnlinkedUseTag);
}
}
@@ -467,6 +482,12 @@ public class QBitService : DownloadService, IQBitService
[DryRunSafeguard]
protected virtual async Task ChangeCategory(string hash, string newCategory)
{
if (_downloadCleanerConfig.UnlinkedUseTag)
{
await _client.AddTorrentTagAsync([hash], newCategory);
return;
}
await _client.SetTorrentCategoryAsync([hash], newCategory);
}

View File

@@ -67,7 +67,7 @@ public abstract class GenericHandler : IHandler, IDisposable
_downloadService.Dispose();
}
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config);
protected async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType, bool throwOnFailure = false)
{
@@ -80,7 +80,7 @@ public abstract class GenericHandler : IHandler, IDisposable
{
try
{
await ProcessInstanceAsync(arrInstance, instanceType);
await ProcessInstanceAsync(arrInstance, instanceType, config);
}
catch (Exception exception)
{

View File

@@ -10,5 +10,5 @@ public interface INotificationPublisher
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
Task NotifyCategoryChanged(string oldCategory, string newCategory);
Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
}

View File

@@ -123,21 +123,29 @@ public class NotificationPublisher : INotificationPublisher
}
}
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory)
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
CategoryChangedNotification notification = new()
{
Title = "Category changed",
Title = isTag? "Tag added" : "Category changed",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
new() { Title = "Old category", Text = oldCategory },
new() { Title = "New category", Text = newCategory }
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() }
],
Level = NotificationLevel.Important
};
if (isTag)
{
notification.Fields.Add(new() { Title = "Tag", Text = newCategory });
}
else
{
notification.Fields.Add(new() { Title = "Old category", Text = oldCategory });
notification.Fields.Add(new() { Title = "New category", Text = newCategory });
}
await NotifyInternal(notification);
}

View File

@@ -49,7 +49,7 @@ public sealed class QueueCleaner : GenericHandler
_ignoredDownloadsProvider = ignoredDownloadsProvider;
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
{
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
@@ -108,7 +108,7 @@ public sealed class QueueCleaner : GenericHandler
}
// failed import check
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate);
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.ImportFailedMaxStrikes);
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.ImportFailed;
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)

View File

@@ -221,15 +221,18 @@ services:
- DOWNLOADCLEANER__ENABLED=true
- DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH=/ignored
- DOWNLOADCLEANER__DELETE_PRIVATE=false
- DOWNLOADCLEANER__CATEGORIES__0__NAME=tv-sonarr
- DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO=-1
- DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME=0
- DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=99999
- DOWNLOADCLEANER__CATEGORIES__1__NAME=nohardlink
- DOWNLOADCLEANER__CATEGORIES__1__NAME=cleanuperr-unlinked
- DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1
- DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=99999
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
- DOWNLOADCLEANER__UNLINKED_USE_TAG=false
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
@@ -249,6 +252,7 @@ services:
# - TRANSMISSION__PASSWORD=testing
- SONARR__ENABLED=true
- SONARR__IMPORT_FAILED_MAX_STRIKES=-1
- SONARR__SEARCHTYPE=Episode
- SONARR__BLOCK__TYPE=blacklist
- SONARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
@@ -256,12 +260,14 @@ services:
- SONARR__INSTANCES__0__APIKEY=425d1e713f0c405cbbf359ac0502c1f4
- RADARR__ENABLED=true
- RADARR__IMPORT_FAILED_MAX_STRIKES=-1
- RADARR__BLOCK__TYPE=blacklist
- RADARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
- RADARR__INSTANCES__0__URL=http://radarr:7878
- RADARR__INSTANCES__0__APIKEY=8b7454f668e54c5b8f44f56f93969761
- LIDARR__ENABLED=true
- LIDARR__IMPORT_FAILED_MAX_STRIKES=-1
- LIDARR__BLOCK__TYPE=blacklist
- LIDARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist # TODO
- LIDARR__INSTANCES__0__URL=http://lidarr:8686

View File

@@ -13,7 +13,7 @@ Cleanuperr was created primarily to address malicious files, such as `*.lnk` or
<Warning>
Because this tool is actively developed and still a work in progress, using the `latest` Docker tag may result in breaking changes.
Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together: https://discord.gg/sWggpnmGNY
Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together: https://discord.gg/SCtMCgtsc4
</Warning>

View File

@@ -3,7 +3,7 @@ sidebar_position: 3
---
import DownloadCleanerHardlinksSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerHardlinksSettings';
import { Important } from '@site/src/components/Admonition';
import { Important, Warning } from '@site/src/components/Admonition';
# Hardlinks Settings
@@ -16,4 +16,8 @@ The Download Cleaner will change the category of a download that has no hardlink
If your download client's download directory is `/downloads`, it should be the same for Cleanuperr.
</Important>
<Warning>
While it is not needed to configure the arrs for this feature, it is recommended you do. If the arrs are not configured, downloads that are waiting to be imported might be affected by it.
</Warning>
<DownloadCleanerHardlinksSettings/>

View File

@@ -12,8 +12,12 @@ services:
image: ghcr.io/flmorg/cleanuperr:latest
restart: unless-stopped
volumes:
# if you want persistent logs
- ./cleanuperr/logs:/var/logs
# if you want to ignore certain downloads from being processed
- ./cleanuperr/ignored.txt:/ignored.txt
# if you're using cross-seed and the hardlinks functionality
- ./downloads:/downloads
environment:
# general settings
- TZ=America/New_York
@@ -87,6 +91,7 @@ services:
# change category for downloads with no hardlinks
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
- DOWNLOADCLEANER__UNLINKED_USE_TAG=false
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
@@ -113,6 +118,7 @@ services:
# - TRANSMISSION__PASSWORD=testing
- SONARR__ENABLED=true
- SONARR__IMPORT_FAILED_MAX_STRIKES=-1
- SONARR__SEARCHTYPE=Episode
- SONARR__BLOCK__TYPE=blacklist
- SONARR__BLOCK__PATH=https://example.com/path/to/file.txt
@@ -122,6 +128,7 @@ services:
- SONARR__INSTANCES__1__APIKEY=secret2
- RADARR__ENABLED=true
- RADARR__IMPORT_FAILED_MAX_STRIKES=-1
- RADARR__BLOCK__TYPE=blacklist
- RADARR__BLOCK__PATH=https://example.com/path/to/file.txt
- RADARR__INSTANCES__0__URL=http://localhost:7878
@@ -130,6 +137,7 @@ services:
- RADARR__INSTANCES__1__APIKEY=secret4
- LIDARR__ENABLED=true
- LIDARR__IMPORT_FAILED_MAX_STRIKES=-1
- LIDARR__BLOCK__TYPE=blacklist
- LIDARR__BLOCK__PATH=https://example.com/path/to/file.txt
- LIDARR__INSTANCES__0__URL=http://radarr:8686

View File

@@ -78,6 +78,7 @@ import { Note } from '@site/src/components/Admonition';
}
],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"DOWNLOADCLEANER__UNLINKED_USE_TAG": false,
"UNLINKED_IGNORED_ROOT_DIR": "/downloads",
"UNLINKED_CATEGORIES": [
"tv-sonarr",
@@ -105,6 +106,7 @@ import { Note } from '@site/src/components/Admonition';
},
"Sonarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES=-1
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
@@ -123,6 +125,7 @@ import { Note } from '@site/src/components/Admonition';
},
"Radarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://example.com/path/to/file.txt"
@@ -140,6 +143,7 @@ import { Note } from '@site/src/components/Admonition';
},
"Lidarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://example.com/path/to/file.txt"

View File

@@ -4,10 +4,13 @@ import { Note } from '@site/src/components/Admonition';
1. **Docker (Recommended)**
Pull the Docker image from `ghcr.io/flmorg/cleanuperr:latest`.
[Configuration example here.](/docs/configuration/examples/docker)
2. **Unraid (for Unraid users)**
Use the Unraid Community App.
[Configuration example here.](/docs/configuration/examples/docker)
3. **Manual Installation (if you're not using Docker)**
Go to [Windows](/docs/installation/windows), [Linux](/docs/installation/linux) or [MacOS](/docs/installation/macos).
[Configuration example here.](/docs/configuration/examples/config-file)
<Note>
Refer to the [Configuration](/docs/category/configuration) section for detailed configuration instructions.

View File

@@ -61,7 +61,7 @@ const config: Config = {
position: 'right',
},
{
href: 'https://discord.gg/sWggpnmGNY',
href: 'https://discord.gg/SCtMCgtsc4',
label: 'Discord',
position: 'right',
}

View File

@@ -81,6 +81,17 @@ const settings: EnvVarProps[] = [
type: "positive integer number",
defaultValue: "100",
required: false,
},
{
name: "HTTP_VALIDATE_CERT",
description: [
"Controls whether to validate SSL certificates for HTTPS connections.",
"Set to `Disabled` to ignore SSL certificate errors."
],
type: "text",
defaultValue: "Enabled",
required: false,
acceptedValues: ["Enabled", "DisabledForLocalAddresses", "Disabled"],
}
];

View File

@@ -12,6 +12,24 @@ const settings: EnvVarProps[] = [
required: false,
acceptedValues: ["true", "false"],
},
{
name: "LIDARR__IMPORT_FAILED_MAX_STRIKES",
description: [
"Number of strikes before removing a failed import. Set to `0` to never remove failed imports.",
"A strike is given when an item fails to be imported."
],
type: "integer number",
defaultValue: "-1",
required: false,
notes: [
"If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).",
"`0` means to never remove failed imports.",
"If not set to `0` or a negative number, the minimum value is `3`.",
],
warnings: [
"The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk."
]
},
{
name: "LIDARR__BLOCK__TYPE",
description: [

View File

@@ -12,6 +12,24 @@ const settings: EnvVarProps[] = [
required: false,
acceptedValues: ["true", "false"],
},
{
name: "RADARR__IMPORT_FAILED_MAX_STRIKES",
description: [
"Number of strikes before removing a failed import. Set to `0` to never remove failed imports.",
"A strike is given when an item fails to be imported."
],
type: "integer number",
defaultValue: "-1",
required: false,
notes: [
"If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).",
"`0` means to never remove failed imports.",
"If not set to `0` or a negative number, the minimum value is `3`.",
],
warnings: [
"The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk."
]
},
{
name: "RADARR__BLOCK__TYPE",
description: [

View File

@@ -12,6 +12,24 @@ const settings: EnvVarProps[] = [
required: false,
acceptedValues: ["true", "false"],
},
{
name: "SONARR__IMPORT_FAILED_MAX_STRIKES",
description: [
"Number of strikes before removing a failed import. Set to `0` to never remove failed imports.",
"A strike is given when an item fails to be imported."
],
type: "integer number",
defaultValue: "-1",
required: false,
notes: [
"If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).",
"`0` means to never remove failed imports.",
"If not set to `0` or a negative number, the minimum value is `3`.",
],
warnings: [
"The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk."
]
},
{
name: "SONARR__BLOCK__TYPE",
description: [

View File

@@ -11,6 +11,20 @@ const settings: EnvVarProps[] = [
defaultValue: "cleanuperr-unlinked",
required: false,
},
{
name: "DOWNLOADCLEANER__UNLINKED_USE_TAG",
description: [
"If set to true, a tag will be set instead of changing the category.",
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
notes: [
"Works only for qBittorrent.",
],
},
{
name: "DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR",
description: [
@@ -30,11 +44,17 @@ const settings: EnvVarProps[] = [
title: "Multiple patterns can be specified using incrementing numbers starting from 0.",
content: `DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr`
}
},
],
type: "text",
defaultValue: "Empty",
required: false,
notes: [
"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 Deluge, the category name is the name of the label.",
"For Transmission, the category name is the last directory from the save location.",
],
}
];

View File

@@ -11,8 +11,8 @@ const settings: EnvVarProps[] = [
type: "positive integer number",
defaultValue: "0",
required: false,
examples: ["0", "3", "10"],
notes: [
"`0` means to never remove failed imports.",
"If not set to `0`, the minimum value is `3`."
]
},

View File

@@ -11,8 +11,8 @@ const settings: EnvVarProps[] = [
type: "positive integer number",
defaultValue: "0",
required: false,
examples: ["0", "3", "10"],
notes: [
"`0` means to never remove stalled downloads.",
"If not set to 0, the minimum value is `3`."
]
},
@@ -59,6 +59,7 @@ const settings: EnvVarProps[] = [
defaultValue: "0",
required: false,
notes: [
"`0` means to never remove downloads stuck while downloading metadata.",
"If not set to `0`, the minimum value is `3`."
]
}