Compare commits

...

5 Commits

Author SHA1 Message Date
Flaminel
1e0127e97e Add more states to be picked up by Download Cleaner (#242) 2025-07-23 23:54:20 +03:00
Flaminel
5bdbc98d68 fixed Docker image path in docs 2025-07-23 11:39:50 +03:00
Flaminel
e1aeb3da31 Try #1 to fix memory leak (#241) 2025-07-22 12:24:38 +03:00
Flaminel
283b09e8f1 fixed release name 2025-07-22 12:03:23 +03:00
Flaminel
b03c96249b Improve torrent protocol detection (#235) 2025-07-07 20:42:59 +03:00
16 changed files with 111 additions and 91 deletions

View File

@@ -106,7 +106,7 @@ jobs:
- name: Create release
uses: softprops/action-gh-release@v2
with:
name: Cleanuparr ${{ needs.validate.outputs.release_version }}
name: ${{ needs.validate.outputs.release_version }}
tag_name: ${{ needs.validate.outputs.release_version }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true

View File

@@ -25,34 +25,32 @@ public static class ServicesDI
{
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddSingleton<IEncryptionService, AesEncryptionService>()
.AddTransient<SensitiveDataJsonConverter>()
.AddTransient<EventsContext>()
.AddTransient<DataContext>()
.AddTransient<EventPublisher>()
.AddScoped<IEncryptionService, AesEncryptionService>()
.AddScoped<SensitiveDataJsonConverter>()
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<EventPublisher>()
.AddHostedService<EventCleanupService>()
// API services
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<CertificateValidationService>()
.AddScoped<SonarrClient>()
.AddScoped<RadarrClient>()
.AddScoped<LidarrClient>()
.AddScoped<ReadarrClient>()
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<ContentBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<ArrQueueIterator>()
.AddScoped<DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddSingleton<IJobManagementService, JobManagementService>()
// Core services
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<CertificateValidationService>()
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<WhisparrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()
.AddTransient<DownloadCleaner>()
.AddTransient<IQueueItemRemover, QueueItemRemover>()
.AddTransient<IDownloadHunter, DownloadHunter>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<IHardLinkFileService, HardLinkFileService>()
.AddTransient<UnixHardLinkFileService>()
.AddTransient<WindowsHardLinkFileService>()
.AddTransient<ArrQueueIterator>()
.AddTransient<DownloadServiceFactory>()
.AddTransient<IStriker, Striker>()
.AddSingleton<BlocklistProvider>();
}

View File

@@ -21,13 +21,16 @@ public static class HostExtensions
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
// Apply db migrations
var eventsContext = app.Services.GetRequiredService<EventsContext>();
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
await using var scope = scopeFactory.CreateAsyncScope();
await using var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
{
await eventsContext.Database.MigrateAsync();
}
var configContext = app.Services.GetRequiredService<DataContext>();
await using var configContext = scope.ServiceProvider.GetRequiredService<DataContext>();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();

View File

@@ -22,18 +22,18 @@ namespace Cleanuparr.Api.Jobs;
public class BackgroundJobManager : IHostedService
{
private readonly ISchedulerFactory _schedulerFactory;
private readonly DataContext _dataContext;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<BackgroundJobManager> _logger;
private IScheduler? _scheduler;
public BackgroundJobManager(
ISchedulerFactory schedulerFactory,
DataContext dataContext,
IServiceScopeFactory scopeFactory,
ILogger<BackgroundJobManager> logger
)
{
_schedulerFactory = schedulerFactory;
_dataContext = dataContext;
_scopeFactory = scopeFactory;
_logger = logger;
}
@@ -86,14 +86,18 @@ public class BackgroundJobManager : IHostedService
throw new InvalidOperationException("Scheduler not initialized");
}
// Use scoped DataContext to prevent memory leaks
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Get configurations from db
QueueCleanerConfig queueCleanerConfig = await _dataContext.QueueCleanerConfigs
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
ContentBlockerConfig contentBlockerConfig = await _dataContext.ContentBlockerConfigs
ContentBlockerConfig contentBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
DownloadCleanerConfig downloadCleanerConfig = await _dataContext.DownloadCleanerConfigs
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);

View File

@@ -9,12 +9,12 @@ public sealed class GenericJob<T> : IJob
where T : IHandler
{
private readonly ILogger<GenericJob<T>> _logger;
private readonly T _handler;
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
private readonly IServiceScopeFactory _scopeFactory;
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_handler = handler;
_scopeFactory = scopeFactory;
}
public async Task Execute(IJobExecutionContext context)
@@ -23,7 +23,9 @@ public sealed class GenericJob<T> : IJob
try
{
await _handler.ExecuteAsync();
await using var scope = _scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
}
catch (Exception ex)
{

View File

@@ -70,7 +70,7 @@ builder.Services.AddCors(options =>
// Register services needed for logging first
builder.Services
.AddTransient<LoggingConfigManager>()
.AddScoped<LoggingConfigManager>()
.AddSingleton<SignalRLogSink>();
// Add logging with proper service provider
@@ -133,21 +133,25 @@ logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}",
await app.Init();
// Get LoggingConfigManager (will be created if not already registered)
var configManager = app.Services.GetRequiredService<LoggingConfigManager>();
// Get the dynamic level switch for controlling log levels
var levelSwitch = configManager.GetLevelSwitch();
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
var configManager = scope.ServiceProvider.GetRequiredService<LoggingConfigManager>();
// Get the dynamic level switch for controlling log levels
var levelSwitch = configManager.GetLevelSwitch();
// Get the SignalRLogSink instance
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
// Get the SignalRLogSink instance
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
logConfig.MinimumLevel.ControlledBy(levelSwitch);
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
logConfig.MinimumLevel.ControlledBy(levelSwitch);
// Add to Serilog pipeline
logConfig.WriteTo.Sink(signalRSink);
// Add to Serilog pipeline
logConfig.WriteTo.Sink(signalRSink);
Log.Logger = logConfig.CreateLogger();
Log.Logger = logConfig.CreateLogger();
}
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions

View File

@@ -107,7 +107,7 @@ public sealed class QueueCleaner : GenericHandler
DownloadCheckResult downloadCheckResult = new();
if (record.Protocol is "torrent")
if (record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase))
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)

View File

@@ -11,15 +11,15 @@ namespace Cleanuparr.Infrastructure.Events;
/// </summary>
public class EventCleanupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<EventCleanupService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours
private readonly int _retentionDays = 30; // Keep events for 30 days
public EventCleanupService(IServiceProvider serviceProvider, ILogger<EventCleanupService> logger)
public EventCleanupService(ILogger<EventCleanupService> logger, IServiceScopeFactory scopeFactory)
{
_serviceProvider = serviceProvider;
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -58,7 +58,7 @@ public class EventCleanupService : BackgroundService
{
try
{
using var scope = _serviceProvider.CreateScope();
await using var scope = _scopeFactory.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<EventsContext>();
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);

View File

@@ -19,7 +19,7 @@ namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
public sealed class BlocklistProvider
{
private readonly ILogger<BlocklistProvider> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IServiceScopeFactory _scopeFactory;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly Dictionary<InstanceType, string> _configHashes = new();
@@ -28,13 +28,13 @@ public sealed class BlocklistProvider
public BlocklistProvider(
ILogger<BlocklistProvider> logger,
IServiceProvider serviceProvider,
IServiceScopeFactory scopeFactory,
IMemoryCache cache,
IHttpClientFactory httpClientFactory
)
{
_logger = logger;
_serviceProvider = serviceProvider;
_scopeFactory = scopeFactory;
_cache = cache;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
@@ -43,7 +43,8 @@ public sealed class BlocklistProvider
{
try
{
var dataContext = _serviceProvider.GetRequiredService<DataContext>();
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
int changedCount = 0;
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()

View File

@@ -20,12 +20,13 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// </summary>
public sealed class DownloadServiceFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<DownloadServiceFactory> _logger;
private readonly IServiceProvider _serviceProvider;
public DownloadServiceFactory(
IServiceProvider serviceProvider,
ILogger<DownloadServiceFactory> logger)
ILogger<DownloadServiceFactory> logger,
IServiceProvider serviceProvider
)
{
_serviceProvider = serviceProvider;
_logger = logger;

View File

@@ -12,7 +12,7 @@ public partial class QBitService
/// <inheritdoc/>
public override async Task<List<object>?> GetSeedingDownloads()
{
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Seeding });
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Completed });
return torrentList?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Cast<object>()
.ToList();

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -13,9 +12,8 @@ namespace Cleanuparr.Infrastructure.Health;
public class HealthCheckService : IHealthCheckService
{
private readonly ILogger<HealthCheckService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly Dictionary<Guid, HealthStatus> _healthStatuses = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly object _lockObject = new();
/// <summary>
@@ -25,12 +23,11 @@ public class HealthCheckService : IHealthCheckService
public HealthCheckService(
ILogger<HealthCheckService> logger,
IServiceProvider serviceProvider,
DownloadServiceFactory downloadServiceFactory)
IServiceScopeFactory scopeFactory
)
{
_logger = logger;
_serviceProvider = serviceProvider;
_downloadServiceFactory = downloadServiceFactory;
_scopeFactory = scopeFactory;
}
/// <inheritdoc />
@@ -40,7 +37,8 @@ public class HealthCheckService : IHealthCheckService
try
{
var dataContext = _serviceProvider.GetRequiredService<DataContext>();
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Get the client configuration
var downloadClientConfig = await dataContext.DownloadClients
@@ -63,7 +61,8 @@ public class HealthCheckService : IHealthCheckService
}
// Get the client instance
var client = _downloadServiceFactory.GetDownloadService(downloadClientConfig);
var downloadServiceFactory = scope.ServiceProvider.GetRequiredService<DownloadServiceFactory>();
var client = downloadServiceFactory.GetDownloadService(downloadClientConfig);
// Execute the health check
var healthResult = await client.HealthCheckAsync();
@@ -107,7 +106,8 @@ public class HealthCheckService : IHealthCheckService
try
{
var dataContext = _serviceProvider.GetRequiredService<DataContext>();
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Get all enabled client configurations
var enabledClients = await dataContext.DownloadClients

View File

@@ -13,16 +13,16 @@ namespace Cleanuparr.Infrastructure.Http;
public class DynamicHttpClientProvider : IDynamicHttpClientProvider
{
private readonly ILogger<DynamicHttpClientProvider> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
public DynamicHttpClientProvider(
ILogger<DynamicHttpClientProvider> logger,
IServiceProvider serviceProvider,
IServiceScopeFactory scopeFactory,
IDynamicHttpClientFactory dynamicHttpClientFactory)
{
_logger = logger;
_serviceProvider = serviceProvider;
_scopeFactory = scopeFactory;
_dynamicHttpClientFactory = dynamicHttpClientFactory;
}
@@ -49,7 +49,8 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider
/// <returns>A configured HttpClient instance</returns>
private HttpClient CreateGenericClient(DownloadClientConfig downloadClientConfig)
{
var dataContext = _serviceProvider.GetRequiredService<DataContext>();
using var scope = _scopeFactory.CreateScope();
using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
var httpConfig = dataContext.GeneralConfigs.First();
var clientName = GetClientName(downloadClientConfig);

View File

@@ -13,16 +13,17 @@ namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
/// </summary>
public class DynamicHttpClientConfiguration : IConfigureNamedOptions<HttpClientFactoryOptions>
{
private readonly IServiceProvider _serviceProvider;
private readonly IServiceScopeFactory _scopeFactory;
public DynamicHttpClientConfiguration(IServiceProvider serviceProvider)
public DynamicHttpClientConfiguration(IServiceScopeFactory scopeFactory)
{
_serviceProvider = serviceProvider;
_scopeFactory = scopeFactory;
}
public void Configure(string name, HttpClientFactoryOptions options)
{
var configStore = _serviceProvider.GetRequiredService<IHttpClientConfigStore>();
using var scope = _scopeFactory.CreateScope();
var configStore = scope.ServiceProvider.GetRequiredService<IHttpClientConfigStore>();
if (!configStore.TryGetConfiguration(name, out HttpClientConfig? config))
return;
@@ -48,7 +49,8 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions<HttpClientF
private void ConfigureHandler(HttpMessageHandlerBuilder builder, HttpClientConfig config)
{
var certValidationService = _serviceProvider.GetRequiredService<CertificateValidationService>();
using var scope = _scopeFactory.CreateScope();
var certValidationService = scope.ServiceProvider.GetRequiredService<CertificateValidationService>();
switch (config.Type)
{

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Persistence;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
@@ -13,24 +14,27 @@ namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
public class HttpClientConfigurationService : IHostedService
{
private readonly IDynamicHttpClientFactory _clientFactory;
private readonly DataContext _dataContext;
private readonly ILogger<HttpClientConfigurationService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public HttpClientConfigurationService(
IDynamicHttpClientFactory clientFactory,
DataContext dataContext,
ILogger<HttpClientConfigurationService> logger)
ILogger<HttpClientConfigurationService> logger,
IServiceScopeFactory scopeFactory)
{
_clientFactory = clientFactory;
_dataContext = dataContext;
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
try
{
var config = await _dataContext.GeneralConfigs
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
var config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);

View File

@@ -52,7 +52,7 @@ docker run -d --name cleanuparr \
-e PGID=1000 \
-e UMASK=022 \
-e TZ=Etc/UTC \
ghcr.io/cleanuparr:latest
ghcr.io/cleanuparr/cleanuparr:latest
```
### Docker Compose