Compare commits

...

8 Commits

Author SHA1 Message Date
Flaminel
de06d1c2d3 Fix download client type being sent as number instead of string (#245) 2025-07-27 14:23:48 +03:00
Flaminel
72855bc030 small fix on how_it_works page of the docs 2025-07-24 18:41:05 +03:00
eatsleepcoderepeat-gl
b185ea6899 Added new whitelist which includes subtitles (#243) 2025-07-24 12:50:03 +03:00
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
25 changed files with 184 additions and 179 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

@@ -2,7 +2,7 @@
public enum DownloadClientTypeName
{
QBittorrent,
qBittorrent,
Deluge,
Transmission,
}

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;
@@ -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")

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

@@ -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',

View File

@@ -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>

View File

@@ -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';
}

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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.

View File

@@ -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>

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

7
whitelist_with_subtitles Normal file
View File

@@ -0,0 +1,7 @@
*.avi
*.mp4
*.mkv
*.ass
*.srt
*.ssa
*.sub