Compare commits

...

8 Commits

Author SHA1 Message Date
Flaminel
ba02aa0e49 Fix notifications failing when poster image is not set (#78) 2025-03-02 22:48:21 +02:00
Flaminel
5adbdbd920 Fix weird time zone display name on startup (#70) 2025-02-25 21:32:19 +02:00
Flaminel
b3b211d956 Add configurable time zone (#69) 2025-02-24 23:21:44 +02:00
Flaminel
279bd6d82d Fix Deluge timeout not being configurable (#68) 2025-02-24 18:32:44 +02:00
Flaminel
5dced28228 fixed errors on download cleaner when download client is none (#67) 2025-02-24 12:43:06 +02:00
Flaminel
51bdaf64e4 Fix interceptor memory leaks (#66) 2025-02-23 17:50:08 +02:00
Flaminel
9c8e0ebedc updated README 2025-02-18 13:16:49 +02:00
Flaminel
e1bea8a8c8 updated README 2025-02-17 23:59:36 +02:00
34 changed files with 309 additions and 253 deletions

View File

@@ -8,12 +8,42 @@ cleanuperr is a tool for automating the cleanup of unwanted or blocked files in
cleanuperr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made cleanuperr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
The tool supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
> [!IMPORTANT]
> **Features:**
> - Strike system to mark stalled or downloads stuck in metadata downloading.
> - Remove and block downloads that reached a maximum number of strikes.
> - Remove downloads blocked by qBittorrent or by cleanuperr's **content blocker**.
> - Trigger a search for downloads removed from the *arrs.
> - Clean up downloads that have been seeding for a certain amount of time.
> - Notify on strike or download removal.
#
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.
> [!WARNING]
> This tool is actively developed and still a work in progress, so 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
## Table of contents:
- [Naming choice](README.md#naming-choice)
- [Quick Start](README.md#quick-start)
- [How it works](README.md#how-it-works)
- [Setup](README.md#setup)
- [Usage](README.md#usage)
- [Docker Compose](README.md#docker-compose-yaml)
- [Environment Variables](README.md#environment-variables)
- [Binaries](README.md#binaries-if-youre-not-using-docker)
- [Credits](README.md#credits)
## Naming choice
I've had people asking why it's `cleanuperr` and not `cleanuparr` and that I should change it. This name was intentional.
I've seen a few discussions on this type of naming and I've decided that I didn't deserve the `arr` moniker since `cleanuperr` is not a fork of `NZB.Drone` and it does not have any affiliation with the arrs. I still wanted to keep the naming style close enough though, to suggest a correlation between them.
## Quick Start
> [!NOTE]
> ### Quick Start
>
> 1. **Docker (Recommended)**
> Pull the Docker image from `ghcr.io/flmorg/cleanuperr:latest`.
@@ -27,10 +57,6 @@ The tool supports both qBittorrent's built-in exclusion features and its own blo
> [!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.
## Key features
- Marks unwanted files as skip/unwanted in the download client.
- Automatically strikes stalled or stuck downloads.
- Removes and blocks downloads that reached the maximum number of strikes or are marked as unwanted by the download client or by cleanuperr and triggers a search for removed downloads.
> [!IMPORTANT]
> Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version:
@@ -41,10 +67,6 @@ The tool supports both qBittorrent's built-in exclusion features and its own blo
> - Radarr
> - Lidarr
This tool is actively developed and still a work in progress, so 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
# How it works
1. **Content blocker** will:
@@ -102,7 +124,8 @@ This tool is actively developed and still a work in progress, so using the `late
3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`.
4. Set `DOWNLOAD_CLIENT` to `none`.
**No other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).**
> [!WARNING]
> When `DOWNLOAD_CLIENT=none`, no other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).
## Usage
@@ -117,6 +140,7 @@ services:
volumes:
- ./cleanuperr/logs:/var/logs
environment:
- TZ=America/New_York
- DRY_RUN=false
- LOGGING__LOGLEVEL=Information

View File

@@ -1,8 +1,6 @@
using System.Net;
using Castle.DynamicProxy;
using Common.Configuration.General;
using Common.Helpers;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.Notifications.Consumers;
using Infrastructure.Verticals.Notifications.Models;
@@ -42,8 +40,7 @@ public static class MainDI
e.PrefetchCount = 1;
});
});
})
.AddDryRunInterceptor();
});
private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration)
{
@@ -65,7 +62,7 @@ public static class MainDI
services
.AddHttpClient(nameof(DelugeService), x =>
{
x.Timeout = TimeSpan.FromSeconds(5);
x.Timeout = TimeSpan.FromSeconds(config.Timeout);
})
.ConfigurePrimaryHttpMessageHandler(_ =>
{
@@ -91,31 +88,4 @@ public static class MainDI
.OrResult(response => !response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Unauthorized)
.WaitAndRetryAsync(config.MaxRetries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))
);
private static IServiceCollection AddDryRunInterceptor(this IServiceCollection services)
{
services
.Where(s => s.ServiceType != typeof(IDryRunService) && typeof(IDryRunService).IsAssignableFrom(s.ServiceType))
.ToList()
.ForEach(service =>
{
services.Decorate(service.ServiceType, (target, svc) =>
{
ProxyGenerator proxyGenerator = new();
DryRunAsyncInterceptor interceptor = svc.GetRequiredService<DryRunAsyncInterceptor>();
object implementation = proxyGenerator.CreateClassProxyWithTarget(
service.ServiceType,
target,
interceptor
);
((IInterceptedService)target).Proxy = implementation;
return implementation;
});
});
return services;
}
}

View File

@@ -10,7 +10,7 @@ public static class NotificationsDI
.Configure<NotifiarrConfig>(configuration.GetSection(NotifiarrConfig.SectionName))
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>()
.AddTransient<NotificationPublisher>()
.AddTransient<INotificationPublisher, NotificationPublisher>()
.AddTransient<INotificationFactory, NotificationFactory>()
.AddTransient<NotificationService>();
}

View File

@@ -15,7 +15,7 @@ public static class ServicesDI
{
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddTransient<DryRunAsyncInterceptor>()
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()

View File

@@ -0,0 +1,23 @@
using System.Reflection;
namespace Executable;
public static class HostExtensions
{
public static IHost Init(this IHost host)
{
ILogger<Program> logger = host.Services.GetRequiredService<ILogger<Program>>();
Version? version = Assembly.GetExecutingAssembly().GetName().Version;
logger.LogInformation(
version is null
? "cleanuperr version not detected"
: $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}"
);
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
return host;
}
}

View File

@@ -1,4 +1,4 @@
using System.Reflection;
using Executable;
using Executable.DependencyInjection;
var builder = Host.CreateApplicationBuilder(args);
@@ -7,15 +7,6 @@ builder.Services.AddInfrastructure(builder.Configuration);
builder.Logging.AddLogging(builder.Configuration);
var host = builder.Build();
var logger = host.Services.GetRequiredService<ILogger<Program>>();
var version = Assembly.GetExecutingAssembly().GetName().Version;
logger.LogInformation(
version is null
? "cleanuperr version not detected"
: $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}"
);
host.Init();
host.Run();

View File

@@ -1,6 +1,7 @@
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.ItemStriker;
@@ -53,7 +54,8 @@ public class DownloadServiceFixture : IDisposable
downloadCleanerOptions.Value.Returns(new DownloadCleanerConfig());
var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
var notifier = Substitute.For<NotificationPublisher>();
var notifier = Substitute.For<INotificationPublisher>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
return new TestDownloadService(
Logger,
@@ -63,7 +65,8 @@ public class DownloadServiceFixture : IDisposable
Cache,
filenameEvaluator,
Striker,
notifier
notifier,
dryRunInterceptor
);
}

View File

@@ -3,6 +3,7 @@ using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.ItemStriker;
@@ -23,9 +24,12 @@ public class TestDownloadService : DownloadService
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
NotificationPublisher notifier)
: base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig,
cache, filenameEvaluator, striker, notifier)
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{
}

View File

@@ -12,7 +12,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Castle.Core.AsyncInterceptor" Version="2.1.0" />
<PackageReference Include="FLM.QBittorrent" Version="1.0.0" />
<PackageReference Include="FLM.Transmission" Version="1.0.2" />
<PackageReference Include="Mapster" Version="7.4.0" />

View File

@@ -1,5 +1,4 @@
using System.Reflection;
using Castle.DynamicProxy;
using Common.Attributes;
using Common.Configuration.General;
using Microsoft.Extensions.Logging;
@@ -7,41 +6,70 @@ using Microsoft.Extensions.Options;
namespace Infrastructure.Interceptors;
public class DryRunAsyncInterceptor : AsyncInterceptorBase
public class DryRunInterceptor : IDryRunInterceptor
{
private readonly ILogger<DryRunAsyncInterceptor> _logger;
private readonly ILogger<DryRunInterceptor> _logger;
private readonly DryRunConfig _config;
public DryRunAsyncInterceptor(ILogger<DryRunAsyncInterceptor> logger, IOptions<DryRunConfig> config)
public DryRunInterceptor(ILogger<DryRunInterceptor> logger, IOptions<DryRunConfig> config)
{
_logger = logger;
_config = config.Value;
}
protected override async Task InterceptAsync(IInvocation invocation, IInvocationProceedInfo proceedInfo, Func<IInvocation, IInvocationProceedInfo, Task> proceed)
public void Intercept(Action action)
{
MethodInfo? method = invocation.MethodInvocationTarget ?? invocation.Method;
if (IsDryRun(method))
MethodInfo methodInfo = action.Method;
if (IsDryRun(methodInfo))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", method.Name);
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return;
}
await proceed(invocation, proceedInfo);
action();
}
protected override async Task<TResult> InterceptAsync<TResult>(IInvocation invocation, IInvocationProceedInfo proceedInfo, Func<IInvocation, IInvocationProceedInfo, Task<TResult>> proceed)
public Task InterceptAsync(Delegate action, params object[] parameters)
{
MethodInfo? method = invocation.MethodInvocationTarget ?? invocation.Method;
if (IsDryRun(method))
MethodInfo methodInfo = action.Method;
if (IsDryRun(methodInfo))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", method.Name);
return default!;
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return Task.CompletedTask;
}
return await proceed(invocation, proceedInfo);
}
object? result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
}
public Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters)
{
MethodInfo methodInfo = action.Method;
if (IsDryRun(methodInfo))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return Task.FromResult(default(T));
}
object? result = action.DynamicInvoke(parameters);
if (result is Task<T?> task)
{
return task;
}
return Task.FromResult(default(T));
}
private bool IsDryRun(MethodInfo method)
{
return method.GetCustomAttributes(typeof(DryRunSafeguardAttribute), true).Any() && _config.IsDryRun;

View File

@@ -0,0 +1,10 @@
namespace Infrastructure.Interceptors;
public interface IDryRunInterceptor
{
void Intercept(Action action);
Task InterceptAsync(Delegate action, params object[] parameters);
Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters);
}

View File

@@ -1,5 +0,0 @@
namespace Infrastructure.Interceptors;
public interface IDryRunService : IInterceptedService
{
}

View File

@@ -1,6 +0,0 @@
namespace Infrastructure.Interceptors;
public interface IInterceptedService
{
public object Proxy { get; set; }
}

View File

@@ -1,21 +0,0 @@
namespace Infrastructure.Interceptors;
public class InterceptedService : IInterceptedService
{
private object? _proxy;
public object Proxy
{
get
{
if (_proxy is null)
{
throw new Exception("Proxy is not set");
}
return _proxy;
}
set => _proxy = value;
}
}

View File

@@ -15,27 +15,22 @@ using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public abstract class ArrClient : InterceptedService, IArrClient, IDryRunService
public abstract class ArrClient : IArrClient
{
protected readonly ILogger<ArrClient> _logger;
protected readonly HttpClient _httpClient;
protected readonly LoggingConfig _loggingConfig;
protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly IStriker _striker;
/// <summary>
/// Constructor to be used by interceptors.
/// </summary>
protected ArrClient()
{
}
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected ArrClient(
ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IStriker striker
IStriker striker,
IDryRunInterceptor dryRunInterceptor
)
{
_logger = logger;
@@ -43,6 +38,7 @@ public abstract class ArrClient : InterceptedService, IArrClient, IDryRunService
_loggingConfig = loggingConfig.Value;
_queueCleanerConfig = queueCleanerConfig.Value;
_striker = striker;
_dryRunInterceptor = dryRunInterceptor;
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
@@ -125,7 +121,8 @@ public abstract class ArrClient : InterceptedService, IArrClient, IDryRunService
using HttpRequestMessage request = new(HttpMethod.Delete, uri);
SetApiKey(request, arrInstance.ApiKey);
using var _ = await ((ArrClient)Proxy).SendRequestAsync(request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation(
removeFromClient

View File

@@ -5,6 +5,7 @@ using Common.Configuration.QueueCleaner;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Domain.Models.Lidarr;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
@@ -15,18 +16,14 @@ namespace Infrastructure.Verticals.Arr;
public class LidarrClient : ArrClient, ILidarrClient
{
/// <inheritdoc/>
public LidarrClient()
{
}
public LidarrClient(
ILogger<LidarrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IStriker striker
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker, dryRunInterceptor)
{
}
@@ -64,7 +61,8 @@ public class LidarrClient : ArrClient, ILidarrClient
try
{
using var _ = await ((LidarrClient)Proxy).SendRequestAsync(request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}

View File

@@ -5,6 +5,7 @@ using Common.Configuration.QueueCleaner;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Domain.Models.Radarr;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
@@ -15,18 +16,14 @@ namespace Infrastructure.Verticals.Arr;
public class RadarrClient : ArrClient, IRadarrClient
{
/// <inheritdoc/>
public RadarrClient()
{
}
public RadarrClient(
ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IStriker striker
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker, dryRunInterceptor)
{
}
@@ -72,7 +69,8 @@ public class RadarrClient : ArrClient, IRadarrClient
try
{
using var _ = await ((RadarrClient)Proxy).SendRequestAsync(request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}

View File

@@ -5,6 +5,7 @@ using Common.Configuration.QueueCleaner;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Domain.Models.Sonarr;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
@@ -16,18 +17,14 @@ namespace Infrastructure.Verticals.Arr;
public class SonarrClient : ArrClient, ISonarrClient
{
/// <inheritdoc/>
public SonarrClient()
{
}
public SonarrClient(
ILogger<SonarrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IStriker striker
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker, dryRunInterceptor)
{
}
@@ -68,8 +65,9 @@ public class SonarrClient : ArrClient, ISonarrClient
try
{
using var _ = await ((SonarrClient)Proxy).SendRequestAsync(request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
}
catch

View File

@@ -36,7 +36,7 @@ public sealed class ContentBlocker : GenericHandler
ArrQueueIterator arrArrQueueIterator,
BlocklistProvider blocklistProvider,
DownloadServiceFactory downloadServiceFactory,
NotificationPublisher notifier
INotificationPublisher notifier
) : base(
logger, downloadClientConfig,
sonarrConfig, radarrConfig, lidarrConfig,

View File

@@ -31,7 +31,7 @@ public sealed class DownloadCleaner : GenericHandler
LidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator,
DownloadServiceFactory downloadServiceFactory,
NotificationPublisher notifier
INotificationPublisher notifier
) : base(
logger, downloadClientConfig,
sonarrConfig, radarrConfig, lidarrConfig,
@@ -46,6 +46,12 @@ public sealed class DownloadCleaner : GenericHandler
public override async Task ExecuteAsync()
{
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None)
{
_logger.LogWarning("download client is set to none");
return;
}
if (_config.Categories?.Count is null or 0)
{
_logger.LogWarning("no categories configured");

View File

@@ -7,11 +7,11 @@ using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Domain.Models.Deluge.Response;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using MassTransit.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -22,11 +22,6 @@ public class DelugeService : DownloadService, IDelugeService
{
private readonly DelugeClient _client;
/// <inheritdoc/>
public DelugeService()
{
}
public DelugeService(
ILogger<DelugeService> logger,
IOptions<DelugeConfig> config,
@@ -37,8 +32,12 @@ public class DelugeService : DownloadService, IDelugeService
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
NotificationPublisher notifier
) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier)
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{
config.Value.Validate();
_client = new (config, httpClientFactory);
@@ -190,7 +189,7 @@ public class DelugeService : DownloadService, IDelugeService
return result;
}
await ((DelugeService)Proxy).ChangeFilesPriority(hash, sortedPriorities);
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);
return result;
}
@@ -245,8 +244,8 @@ public class DelugeService : DownloadService, IDelugeService
{
continue;
}
await ((DelugeService)Proxy).DeleteDownload(download.Hash);
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",

View File

@@ -18,7 +18,7 @@ using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient;
public abstract class DownloadService : InterceptedService, IDownloadService
public abstract class DownloadService : IDownloadService
{
protected readonly ILogger<DownloadService> _logger;
protected readonly QueueCleanerConfig _queueCleanerConfig;
@@ -28,15 +28,9 @@ public abstract class DownloadService : InterceptedService, IDownloadService
protected readonly IFilenameEvaluator _filenameEvaluator;
protected readonly IStriker _striker;
protected readonly MemoryCacheEntryOptions _cacheOptions;
protected readonly NotificationPublisher _notifier;
protected readonly INotificationPublisher _notifier;
protected readonly IDryRunInterceptor _dryRunInterceptor;
/// <summary>
/// Constructor to be used by interceptors.
/// </summary>
protected DownloadService()
{
}
protected DownloadService(
ILogger<DownloadService> logger,
IOptions<QueueCleanerConfig> queueCleanerConfig,
@@ -45,7 +39,9 @@ public abstract class DownloadService : InterceptedService, IDownloadService
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
NotificationPublisher notifier)
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
)
{
_logger = logger;
_queueCleanerConfig = queueCleanerConfig.Value;
@@ -55,6 +51,7 @@ public abstract class DownloadService : InterceptedService, IDownloadService
_filenameEvaluator = filenameEvaluator;
_striker = striker;
_notifier = notifier;
_dryRunInterceptor = dryRunInterceptor;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}

View File

@@ -3,6 +3,7 @@ using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
@@ -14,12 +15,7 @@ namespace Infrastructure.Verticals.DownloadClient;
public class DummyDownloadService : DownloadService
{
/// <inheritdoc/>
public DummyDownloadService()
{
}
public DummyDownloadService(ILogger<DownloadService> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<DownloadCleanerConfig> downloadCleanerConfig, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, NotificationPublisher notifier) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier)
public DummyDownloadService(ILogger<DownloadService> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<DownloadCleanerConfig> downloadCleanerConfig, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier, dryRunInterceptor)
{
}

View File

@@ -6,7 +6,7 @@ using Infrastructure.Interceptors;
namespace Infrastructure.Verticals.DownloadClient;
public interface IDownloadService : IDisposable, IDryRunService
public interface IDownloadService : IDisposable
{
public Task LoginAsync();

View File

@@ -1,5 +1,5 @@
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
public interface IQBitService : IDownloadService
public interface IQBitService : IDownloadService, IDisposable
{
}

View File

@@ -7,6 +7,7 @@ using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Common.Helpers;
using Domain.Enums;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.ItemStriker;
@@ -24,11 +25,6 @@ public class QBitService : DownloadService, IQBitService
private readonly QBitConfig _config;
private readonly QBittorrentClient _client;
/// <inheritdoc/>
public QBitService()
{
}
public QBitService(
ILogger<QBitService> logger,
IHttpClientFactory httpClientFactory,
@@ -39,8 +35,12 @@ public class QBitService : DownloadService, IQBitService
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
NotificationPublisher notifier
) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier)
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{
_config = config.Value;
_config.Validate();
@@ -200,7 +200,7 @@ public class QBitService : DownloadService, IQBitService
foreach (int fileIndex in unwantedFiles)
{
await ((QBitService)Proxy).SkipFile(hash, fileIndex);
await _dryRunInterceptor.InterceptAsync(SkipFile, hash, fileIndex);
}
return result;
@@ -272,7 +272,7 @@ public class QBitService : DownloadService, IQBitService
continue;
}
await ((QBitService)Proxy).DeleteDownload(download.Hash);
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",

View File

@@ -7,6 +7,7 @@ using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Common.Helpers;
using Domain.Enums;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.ItemStriker;
@@ -26,11 +27,6 @@ public class TransmissionService : DownloadService, ITransmissionService
private readonly Client _client;
private TorrentInfo[]? _torrentsCache;
/// <inheritdoc/>
public TransmissionService()
{
}
public TransmissionService(
IHttpClientFactory httpClientFactory,
ILogger<TransmissionService> logger,
@@ -41,8 +37,12 @@ public class TransmissionService : DownloadService, ITransmissionService
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
NotificationPublisher notifier
) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier)
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{
_config = config.Value;
_config.Validate();
@@ -174,8 +174,8 @@ public class TransmissionService : DownloadService, ITransmissionService
}
_logger.LogDebug("changing priorities | torrent {hash}", hash);
await ((TransmissionService)Proxy).SetUnwantedFiles(torrent.Id, unwantedFiles.ToArray());
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, torrent.Id, unwantedFiles.ToArray());
return result;
}
@@ -268,7 +268,7 @@ public class TransmissionService : DownloadService, ITransmissionService
continue;
}
await ((TransmissionService)Proxy).RemoveDownloadAsync(download.Id);
await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, download.Id);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",

View File

@@ -1,6 +1,7 @@
using Common.Helpers;
using Domain.Enums;
using Infrastructure.Helpers;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory;
@@ -13,13 +14,15 @@ public sealed class Striker : IStriker
private readonly ILogger<Striker> _logger;
private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly NotificationPublisher _notifier;
private readonly INotificationPublisher _notifier;
private readonly IDryRunInterceptor _dryRunInterceptor;
public Striker(ILogger<Striker> logger, IMemoryCache cache, NotificationPublisher notifier)
public Striker(ILogger<Striker> logger, IMemoryCache cache, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor)
{
_logger = logger;
_cache = cache;
_notifier = notifier;
_dryRunInterceptor = dryRunInterceptor;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}

View File

@@ -24,7 +24,7 @@ public abstract class GenericHandler : IHandler, IDisposable
protected readonly ILidarrClient _lidarrClient;
protected readonly ArrQueueIterator _arrArrQueueIterator;
protected readonly IDownloadService _downloadService;
protected readonly NotificationPublisher _notifier;
protected readonly INotificationPublisher _notifier;
protected GenericHandler(
ILogger<GenericHandler> logger,
@@ -37,7 +37,7 @@ public abstract class GenericHandler : IHandler, IDisposable
ILidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator,
DownloadServiceFactory downloadServiceFactory,
NotificationPublisher notifier
INotificationPublisher notifier
)
{
_logger = logger;

View File

@@ -0,0 +1,12 @@
using Domain.Enums;
namespace Infrastructure.Verticals.Notifications;
public interface INotificationPublisher
{
Task NotifyStrike(StrikeType strikeType, int strikeCount);
Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason);
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
}

View File

@@ -12,25 +12,19 @@ using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Notifications;
public class NotificationPublisher : InterceptedService, IDryRunService
public class NotificationPublisher : INotificationPublisher
{
private readonly ILogger<NotificationPublisher> _logger;
private readonly ILogger<INotificationPublisher> _logger;
private readonly IBus _messageBus;
/// <summary>
/// Constructor to be used by interceptors.
/// </summary>
public NotificationPublisher()
{
}
public NotificationPublisher(ILogger<NotificationPublisher> logger, IBus messageBus)
private readonly IDryRunInterceptor _dryRunInterceptor;
public NotificationPublisher(ILogger<INotificationPublisher> logger, IBus messageBus, IDryRunInterceptor dryRunInterceptor)
{
_logger = logger;
_messageBus = messageBus;
_dryRunInterceptor = dryRunInterceptor;
}
[DryRunSafeguard]
public virtual async Task NotifyStrike(StrikeType strikeType, int strikeCount)
{
try
@@ -38,7 +32,7 @@ public class NotificationPublisher : InterceptedService, IDryRunService
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri imageUrl = GetImageFromContext(record, instanceType);
Uri? imageUrl = GetImageFromContext(record, instanceType);
ArrNotification notification = new()
{
@@ -54,10 +48,10 @@ public class NotificationPublisher : InterceptedService, IDryRunService
switch (strikeType)
{
case StrikeType.Stalled:
await _messageBus.Publish(notification.Adapt<StalledStrikeNotification>());
await _dryRunInterceptor.InterceptAsync(Notify<StalledStrikeNotification>, notification.Adapt<StalledStrikeNotification>());
break;
case StrikeType.ImportFailed:
await _messageBus.Publish(notification.Adapt<FailedImportStrikeNotification>());
await _dryRunInterceptor.InterceptAsync(Notify<FailedImportStrikeNotification>, notification.Adapt<FailedImportStrikeNotification>());
break;
}
}
@@ -67,54 +61,84 @@ public class NotificationPublisher : InterceptedService, IDryRunService
}
}
[DryRunSafeguard]
public virtual async Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason)
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri imageUrl = GetImageFromContext(record, instanceType);
QueueItemDeletedNotification notification = new()
try
{
InstanceType = instanceType,
InstanceUrl = instanceUrl,
Hash = record.DownloadId.ToLowerInvariant(),
Title = $"Deleting item from queue with reason: {reason}",
Description = record.Title,
Image = imageUrl,
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
};
await _messageBus.Publish(notification);
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri? imageUrl = GetImageFromContext(record, instanceType);
QueueItemDeletedNotification notification = new()
{
InstanceType = instanceType,
InstanceUrl = instanceUrl,
Hash = record.DownloadId.ToLowerInvariant(),
Title = $"Deleting item from queue with reason: {reason}",
Description = record.Title,
Image = imageUrl,
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
};
await _dryRunInterceptor.InterceptAsync(Notify<QueueItemDeletedNotification>, notification);
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to notify queue item deleted");
}
}
public virtual async Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
try
{
DownloadCleanedNotification notification = new()
{
Title = $"Cleaned item from download client with reason: {reason}",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
new() { Title = "Category", Text = categoryName.ToLowerInvariant() },
new() { Title = "Ratio", Text = $"{ratio.ToString(CultureInfo.InvariantCulture)}%" },
new()
{
Title = "Seeding hours", Text = $"{Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture)}h"
}
],
Level = NotificationLevel.Important
};
await _dryRunInterceptor.InterceptAsync(Notify<DownloadCleanedNotification>, notification);
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to notify download cleaned");
}
}
[DryRunSafeguard]
public virtual async Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
private Task Notify<T>(T message) where T: notnull
{
DownloadCleanedNotification notification = new()
{
Title = $"Cleaned item from download client with reason: {reason}",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
new() { Title = "Category", Text = categoryName.ToLowerInvariant() },
new() { Title = "Ratio", Text = $"{ratio.ToString(CultureInfo.InvariantCulture)}%" },
new() { Title = "Seeding hours", Text = $"{Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture)}h" }
],
Level = NotificationLevel.Important
};
await _messageBus.Publish(notification);
return _messageBus.Publish(message);
}
private static Uri GetImageFromContext(QueueRecord record, InstanceType instanceType) =>
instanceType switch
private Uri? GetImageFromContext(QueueRecord record, InstanceType instanceType)
{
Uri? image = instanceType switch
{
InstanceType.Sonarr => record.Series!.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,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
} ?? throw new Exception("failed to get image url from context");
};
if (image is null)
{
_logger.LogWarning("no poster found for {title}", record.Title);
}
return image;
}
}

View File

@@ -32,7 +32,7 @@ public sealed class QueueCleaner : GenericHandler
LidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator,
DownloadServiceFactory downloadServiceFactory,
NotificationPublisher notifier
INotificationPublisher notifier
) : base(
logger, downloadClientConfig,
sonarrConfig, radarrConfig, lidarrConfig,

View File

@@ -175,6 +175,7 @@ services:
image: ghcr.io/flmorg/cleanuperr:latest
container_name: cleanuperr
environment:
- TZ=Europe/Bucharest
- DRY_RUN=false
- LOGGING__LOGLEVEL=Debug

View File

@@ -12,6 +12,13 @@
### General settings
**`TZ`**
- The time zone to use.
- Type: String.
- Possible values: Any valid timezone.
- Default: `UTC`.
- Required: No.
**`DRY_RUN`**
- When enabled, simulates irreversible operations (like deletions and notifications) without making actual changes.
- Type: Boolean.