mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-02 02:47:52 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de06d1c2d3 | ||
|
|
72855bc030 | ||
|
|
b185ea6899 | ||
|
|
1e0127e97e | ||
|
|
5bdbc98d68 | ||
|
|
e1aeb3da31 | ||
|
|
283b09e8f1 | ||
|
|
b03c96249b |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
public enum DownloadClientTypeName
|
||||
{
|
||||
QBittorrent,
|
||||
qBittorrent,
|
||||
Deluge,
|
||||
Transmission,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
@@ -45,7 +46,7 @@ public sealed class DownloadServiceFactory
|
||||
|
||||
return downloadClientConfig.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.QBittorrent => CreateQBitService(downloadClientConfig),
|
||||
DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig),
|
||||
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
|
||||
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
|
||||
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export class DocumentationService {
|
||||
'download-client': {
|
||||
'enabled': 'enable-download-client',
|
||||
'name': 'client-name',
|
||||
'type': 'client-type',
|
||||
'typeName': 'client-type',
|
||||
'host': 'client-host',
|
||||
'urlBase': 'url-base-path',
|
||||
'username': 'username',
|
||||
|
||||
@@ -147,21 +147,21 @@
|
||||
<div class="field">
|
||||
<label for="client-type">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('type')"
|
||||
(click)="openFieldDocs('typeName')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Client Type *
|
||||
</label>
|
||||
<p-select
|
||||
id="client-type"
|
||||
formControlName="type"
|
||||
[options]="clientTypeOptions"
|
||||
formControlName="typeName"
|
||||
[options]="typeNameOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select client type"
|
||||
appendTo="body"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(clientForm, 'type', 'required')" class="p-error">Client type is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="p-error">Client type is required</small>
|
||||
</div>
|
||||
|
||||
<ng-container>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
|
||||
import { DownloadClientConfigStore } from "./download-client-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
|
||||
import { DownloadClientType } from "../../shared/models/enums";
|
||||
import { DownloadClientType, DownloadClientTypeName } from "../../shared/models/enums";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
|
||||
// PrimeNG Components
|
||||
@@ -56,11 +56,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
editingClient: ClientConfig | null = null;
|
||||
|
||||
// Download client type options
|
||||
clientTypeOptions = [
|
||||
{ label: "qBittorrent", value: DownloadClientType.QBittorrent },
|
||||
{ label: "Deluge", value: DownloadClientType.Deluge },
|
||||
{ label: "Transmission", value: DownloadClientType.Transmission },
|
||||
];
|
||||
typeNameOptions: { label: string, value: DownloadClientTypeName }[] = [];
|
||||
|
||||
// Clean up subscriptions
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -89,7 +85,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
// Initialize client form for modal
|
||||
this.clientForm = this.formBuilder.group({
|
||||
name: ['', Validators.required],
|
||||
type: [null, Validators.required],
|
||||
typeName: [null, Validators.required],
|
||||
host: ['', [Validators.required, this.uriValidator.bind(this)]],
|
||||
username: [''],
|
||||
password: [''],
|
||||
@@ -97,11 +93,19 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
enabled: [true]
|
||||
});
|
||||
|
||||
// Initialize type name options
|
||||
for (const key of Object.keys(DownloadClientTypeName)) {
|
||||
this.typeNameOptions.push({
|
||||
label: key,
|
||||
value: DownloadClientTypeName[key as keyof typeof DownloadClientTypeName]
|
||||
});
|
||||
}
|
||||
|
||||
// Load Download Client config data
|
||||
this.downloadClientStore.loadConfig();
|
||||
|
||||
// Setup client type change handler
|
||||
this.clientForm.get('type')?.valueChanges
|
||||
this.clientForm.get('typeName')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.onClientTypeChange();
|
||||
@@ -184,14 +188,9 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.modalMode = 'edit';
|
||||
this.editingClient = client;
|
||||
|
||||
// Map backend type to frontend type
|
||||
const frontendType = client.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client.type;
|
||||
|
||||
this.clientForm.patchValue({
|
||||
name: client.name,
|
||||
type: frontendType,
|
||||
typeName: client.typeName,
|
||||
host: client.host,
|
||||
username: client.username,
|
||||
password: client.password,
|
||||
@@ -222,28 +221,27 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
const formValue = this.clientForm.value;
|
||||
const mappedType = this.mapClientTypeForBackend(formValue.type);
|
||||
|
||||
const clientData: CreateDownloadClientDto = {
|
||||
name: formValue.name,
|
||||
typeName: mappedType.typeName,
|
||||
type: mappedType.type,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
urlBase: formValue.urlBase,
|
||||
enabled: formValue.enabled
|
||||
};
|
||||
|
||||
if (this.modalMode === 'add') {
|
||||
const clientData: CreateDownloadClientDto = {
|
||||
name: formValue.name,
|
||||
type: this.mapTypeNameToType(formValue.typeName),
|
||||
typeName: formValue.typeName,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
urlBase: formValue.urlBase,
|
||||
enabled: formValue.enabled
|
||||
};
|
||||
|
||||
this.downloadClientStore.createClient(clientData);
|
||||
} else if (this.editingClient) {
|
||||
// For updates, create a proper ClientConfig object
|
||||
const clientConfig: ClientConfig = {
|
||||
id: this.editingClient.id!,
|
||||
id: this.editingClient.id,
|
||||
name: formValue.name,
|
||||
type: formValue.type, // Keep the frontend enum type
|
||||
typeName: mappedType.typeName,
|
||||
type: this.mapTypeNameToType(formValue.typeName),
|
||||
typeName: formValue.typeName,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
@@ -325,42 +323,24 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
/**
|
||||
* Map frontend client type to backend TypeName and Type
|
||||
* Map typeName to type category
|
||||
*/
|
||||
private mapClientTypeForBackend(frontendType: DownloadClientType): { typeName: string, type: string } {
|
||||
switch (frontendType) {
|
||||
case DownloadClientType.QBittorrent:
|
||||
return { typeName: 'qBittorrent', type: 'Torrent' };
|
||||
case DownloadClientType.Deluge:
|
||||
return { typeName: 'Deluge', type: 'Torrent' };
|
||||
case DownloadClientType.Transmission:
|
||||
return { typeName: 'Transmission', type: 'Torrent' };
|
||||
private mapTypeNameToType(typeName: DownloadClientTypeName): DownloadClientType {
|
||||
switch (typeName) {
|
||||
case DownloadClientTypeName.qBittorrent:
|
||||
case DownloadClientTypeName.Deluge:
|
||||
case DownloadClientTypeName.Transmission:
|
||||
return DownloadClientType.Torrent;
|
||||
default:
|
||||
return { typeName: 'QBittorrent', type: 'Torrent' };
|
||||
throw new Error(`Unknown client type name: ${typeName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend TypeName to frontend client type
|
||||
*/
|
||||
private mapClientTypeFromBackend(backendTypeName: string): DownloadClientType {
|
||||
switch (backendTypeName) {
|
||||
case 'QBittorrent':
|
||||
return DownloadClientType.QBittorrent;
|
||||
case 'Deluge':
|
||||
return DownloadClientType.Deluge;
|
||||
case 'Transmission':
|
||||
return DownloadClientType.Transmission;
|
||||
default:
|
||||
return DownloadClientType.QBittorrent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client type changes to update validation
|
||||
*/
|
||||
onClientTypeChange(): void {
|
||||
const clientType = this.clientForm.get('type')?.value;
|
||||
const clientTypeName = this.clientForm.get('typeName')?.value;
|
||||
const hostControl = this.clientForm.get('host');
|
||||
const usernameControl = this.clientForm.get('username');
|
||||
const urlBaseControl = this.clientForm.get('urlBase');
|
||||
@@ -373,13 +353,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
]);
|
||||
|
||||
// Clear username value and remove validation for Deluge
|
||||
if (clientType === DownloadClientType.Deluge) {
|
||||
if (clientTypeName === DownloadClientTypeName.Deluge) {
|
||||
usernameControl.setValue('');
|
||||
usernameControl.clearValidators();
|
||||
}
|
||||
|
||||
// Set default URL base for Transmission
|
||||
if (clientType === DownloadClientType.Transmission) {
|
||||
if (clientTypeName === DownloadClientTypeName.Transmission) {
|
||||
urlBaseControl.setValue('transmission');
|
||||
}
|
||||
|
||||
@@ -392,19 +372,15 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
* Check if username field should be shown (hidden for Deluge)
|
||||
*/
|
||||
shouldShowUsernameField(): boolean {
|
||||
const clientType = this.clientForm.get('type')?.value;
|
||||
return clientType !== DownloadClientType.Deluge;
|
||||
const clientTypeName = this.clientForm.get('typeName')?.value;
|
||||
return clientTypeName !== DownloadClientTypeName.Deluge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client type label for display
|
||||
*/
|
||||
getClientTypeLabel(client: ClientConfig): string {
|
||||
const frontendType = client.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client.type;
|
||||
|
||||
const option = this.clientTypeOptions.find(opt => opt.value === frontendType);
|
||||
const option = this.typeNameOptions.find(opt => opt.value === client.typeName);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DownloadClientType } from './enums';
|
||||
import { DownloadClientType, DownloadClientTypeName } from './enums';
|
||||
|
||||
/**
|
||||
* Represents a download client configuration object
|
||||
@@ -37,7 +37,7 @@ export interface ClientConfig {
|
||||
/**
|
||||
* Type name of download client (backend enum)
|
||||
*/
|
||||
typeName?: string;
|
||||
typeName: DownloadClientTypeName;
|
||||
|
||||
/**
|
||||
* Host address for the download client
|
||||
@@ -73,16 +73,16 @@ export interface CreateDownloadClientDto {
|
||||
* Friendly name for this client
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Type of download client (backend enum)
|
||||
*/
|
||||
type: DownloadClientType;
|
||||
|
||||
/**
|
||||
* Type name of download client (backend enum)
|
||||
*/
|
||||
typeName: string;
|
||||
|
||||
/**
|
||||
* Type of download client (backend enum)
|
||||
*/
|
||||
type: string;
|
||||
typeName: DownloadClientTypeName;
|
||||
|
||||
/**
|
||||
* Host address for the download client
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Download client type enum matching backend DownloadClientType
|
||||
*/
|
||||
export enum DownloadClientType {
|
||||
QBittorrent = 0,
|
||||
Deluge = 1,
|
||||
Transmission = 2,
|
||||
Torrent = "Torrent",
|
||||
Usenet = "Usenet",
|
||||
}
|
||||
|
||||
export enum DownloadClientTypeName {
|
||||
qBittorrent = "qBittorrent",
|
||||
Deluge = "Deluge",
|
||||
Transmission = "Transmission",
|
||||
}
|
||||
@@ -42,7 +42,7 @@ This is a detailed explanation of how the recurring cleanup jobs work.
|
||||
icon="🧹"
|
||||
>
|
||||
|
||||
- Run every 5 minutes (or configured cron, or right after `Content Blocker`).
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**.
|
||||
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
|
||||
|
||||
@@ -140,7 +140,7 @@ Controls how the blocklist is interpreted:
|
||||
- **Whitelist**: Only files matching patterns in the list will be allowed.
|
||||
|
||||
:::tip
|
||||
[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) can be used for Sonarr and Radarr.
|
||||
[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive), [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) and [this whitelist with subtitles](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist_with_subtitles) can be used for Sonarr and Radarr.
|
||||
:::
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
@@ -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
|
||||
|
||||
7
whitelist_with_subtitles
Normal file
7
whitelist_with_subtitles
Normal file
@@ -0,0 +1,7 @@
|
||||
*.avi
|
||||
*.mp4
|
||||
*.mkv
|
||||
*.ass
|
||||
*.srt
|
||||
*.ssa
|
||||
*.sub
|
||||
Reference in New Issue
Block a user