Compare commits

...

11 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
Flaminel
2971445090 Add handling type of malware when containing thepirateheaven.org file (#232) 2025-07-07 14:29:39 +03:00
Flaminel
55c23419cd Improve download removal to be separate from download search (#233) 2025-07-07 14:28:34 +03:00
Flaminel
c4b9d9503a Add more logs for debug (#201) 2025-07-07 14:28:15 +03:00
44 changed files with 440 additions and 210 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

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
@@ -28,6 +29,8 @@ public static class MainDI
{
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
@@ -50,6 +53,14 @@ public static class MainDI
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});

View File

@@ -5,6 +5,8 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -23,33 +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<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

@@ -52,7 +52,7 @@ public sealed class ContentBlocker : GenericHandler
var config = ContextProvider.Get<ContentBlockerConfig>();
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled)
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
{
_logger.LogWarning("No blocklists are enabled");
return;
@@ -183,6 +183,10 @@ public sealed class ContentBlocker : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (!result.ShouldRemove)
@@ -206,7 +210,7 @@ public sealed class ContentBlocker : GenericHandler
record,
group.Count() > 1,
removeFromClient,
DeleteReason.AllFilesBlocked
result.DeleteReason
);
}
});

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)
@@ -141,6 +141,10 @@ public sealed class QueueCleaner : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
var config = ContextProvider.Get<QueueCleanerConfig>();

View File

@@ -11,4 +11,5 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
MalwareFileFound,
}

View File

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

View File

@@ -6,10 +6,6 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Features\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />

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()
@@ -106,6 +107,17 @@ public sealed class BlocklistProvider
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
changedCount++;
}
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
public sealed record BlockFilesResult
{
@@ -13,4 +15,6 @@ public sealed record BlockFilesResult
public bool IsPrivate { get; set; }
public bool Found { get; set; }
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
}

View File

@@ -75,8 +75,21 @@ public partial class DelugeService
totalFiles++;
int priority = file.Priority;
if (result.ShouldRemove)
{
return;
}
if (IsDefinitelyMalware(name))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority is 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
}
@@ -88,9 +101,15 @@ public partial class DelugeService
_logger.LogInformation("unwanted file found | {file}", file.Path);
}
_logger.LogTrace("File is valid | {file}", file.Path);
priorities.Add(file.Index, priority);
});
if (result.ShouldRemove)
{
return result;
}
if (!hasPriorityUpdates)
{
return result;
@@ -105,8 +124,12 @@ public partial class DelugeService
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);

View File

@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
@@ -61,6 +59,7 @@ public partial class DelugeService
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogTrace("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
@@ -95,11 +94,13 @@ public partial class DelugeService
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogTrace("skip slow check | item is in {state} state | {name}", download.State, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -137,6 +138,7 @@ public partial class DelugeService
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", status.Name);
return (false, DeleteReason.None);
}
@@ -149,11 +151,13 @@ public partial class DelugeService
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", status.State, status.Name);
return (false, DeleteReason.None);
}
if (status.Eta > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", status.Name);
return (false, DeleteReason.None);
}

View File

@@ -100,6 +100,16 @@ public abstract class DownloadService : IDownloadService
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
protected bool IsDefinitelyMalware(string filename)
{
if (filename.Contains("thepirateheaven.org", StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
return false;
}
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
{

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

@@ -62,6 +62,7 @@ public partial class QBitService
if (files is null)
{
_logger.LogDebug("torrent {hash} has no files", hash);
return result;
}
@@ -78,19 +79,30 @@ public partial class QBitService
{
if (!file.Index.HasValue)
{
_logger.LogTrace("Skipping file with no index | {file}", file.Name);
continue;
}
totalFiles++;
if (IsDefinitelyMalware(file.Name))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (file.Priority is TorrentContentPriority.Skip)
{
_logger.LogTrace("File is already skipped | {file}", file.Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", file.Name);
continue;
}
@@ -101,13 +113,18 @@ public partial class QBitService
if (unwantedFiles.Count is 0)
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
foreach (int fileIndex in unwantedFiles)
{

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

@@ -55,11 +55,13 @@ public partial class QBitService
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", download.Name);
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
return result;
}
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
}
@@ -87,16 +89,19 @@ public partial class QBitService
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogDebug("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
{
_logger.LogDebug("skip slow check | download is in {state} state | {name}", download.State, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogDebug("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -134,6 +139,7 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.MaxStrikes is 0 && queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
{
_logger.LogDebug("skip stalled check | max strikes is 0 | {name}", torrent.Name);
return (false, DeleteReason.None);
}
@@ -141,6 +147,7 @@ public partial class QBitService
and not TorrentState.ForcedFetchingMetadata)
{
// ignore other states
_logger.LogDebug("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
return (false, DeleteReason.None);
}
@@ -168,6 +175,7 @@ public partial class QBitService
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
}
_logger.LogDebug("skip stalled check | download is not stalled | {name}", torrent.Name);
return (false, DeleteReason.None);
}
}

View File

@@ -62,19 +62,30 @@ public partial class TransmissionService
{
if (download.FileStats?[i].Wanted == null)
{
_logger.LogTrace("Skipping file with no stats | {file}", download.Files[i].Name);
continue;
}
totalFiles++;
if (IsDefinitelyMalware(download.Files[i].Name))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (!download.FileStats[i].Wanted.Value)
{
_logger.LogTrace("File is already skipped | {file}", download.Files[i].Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", download.Files[i].Name);
continue;
}
@@ -85,15 +96,18 @@ public partial class TransmissionService
if (unwantedFiles.Count is 0)
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());

View File

@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
@@ -56,6 +54,7 @@ public partial class TransmissionService
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
@@ -100,11 +99,13 @@ public partial class TransmissionService
if (download.Status is not 4)
{
// not in downloading state
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.Status, download.Name);
return (false, DeleteReason.None);
}
if (download.RateDownload <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -142,17 +143,20 @@ public partial class TransmissionService
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Status is not 4)
{
// not in downloading state
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.Status, download.Name);
return (false, DeleteReason.None);
}
if (download.RateDownload > 0 || download.Eta > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
return (false, DeleteReason.None);
}

View File

@@ -0,0 +1,36 @@
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
public class DownloadHunterConsumer<T> : IConsumer<DownloadHuntRequest<T>>
where T : SearchItem
{
private readonly ILogger<DownloadHunterConsumer<T>> _logger;
private readonly IDownloadHunter _downloadHunter;
public DownloadHunterConsumer(ILogger<DownloadHunterConsumer<T>> logger, IDownloadHunter downloadHunter)
{
_logger = logger;
_downloadHunter = downloadHunter;
}
public async Task Consume(ConsumeContext<DownloadHuntRequest<T>> context)
{
try
{
await _downloadHunter.HuntDownloadsAsync(context.Message);
}
catch (Exception exception)
{
_logger.LogError(exception,
"failed to search for replacement | {title} | {url}",
context.Message.Record.Title,
context.Message.Instance.Url
);
}
}
}

View File

@@ -0,0 +1,42 @@
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Data.Models.Arr;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter;
public sealed class DownloadHunter : IDownloadHunter
{
private readonly DataContext _dataContext;
private readonly ArrClientFactory _arrClientFactory;
public DownloadHunter(
DataContext dataContext,
ArrClientFactory arrClientFactory
)
{
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
public async Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request)
where T : SearchItem
{
var generalConfig = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
if (!generalConfig.SearchEnabled)
{
return;
}
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// prevent tracker spamming
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay));
}
}

View File

@@ -0,0 +1,9 @@
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
public interface IDownloadHunter
{
Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request) where T : SearchItem;
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
public sealed record DownloadHuntRequest<T>
where T : SearchItem
{
public required InstanceType InstanceType { get; init; }
public required ArrInstance Instance { get; init; }
public required T SearchItem { get; init; }
public required QueueRecord Record { get; init; }
}

View File

@@ -30,7 +30,7 @@ public class DownloadRemoverConsumer<T> : IConsumer<QueueItemRemoveRequest<T>>
catch (Exception exception)
{
_logger.LogError(exception,
"failed to remove queue item| {title} | {url}",
"failed to remove queue item | {title} | {url}",
context.Message.Record.Title,
context.Message.Instance.Url
);

View File

@@ -1,34 +1,35 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using System.Net;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Microsoft.EntityFrameworkCore;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
namespace Cleanuparr.Infrastructure.Features.DownloadRemover;
public sealed class QueueItemRemover : IQueueItemRemover
{
private readonly DataContext _dataContext;
private readonly IBus _messageBus;
private readonly IMemoryCache _cache;
private readonly ArrClientFactory _arrClientFactory;
private readonly EventPublisher _eventPublisher;
public QueueItemRemover(
DataContext dataContext,
IBus messageBus,
IMemoryCache cache,
ArrClientFactory arrClientFactory,
EventPublisher eventPublisher
)
{
_dataContext = dataContext;
_messageBus = messageBus;
_cache = cache;
_arrClientFactory = arrClientFactory;
_eventPublisher = eventPublisher;
@@ -39,31 +40,35 @@ public sealed class QueueItemRemover : IQueueItemRemover
{
try
{
var generalConfig = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Set context for EventPublisher
ContextProvider.Set("downloadName", request.Record.Title);
ContextProvider.Set("hash", request.Record.DownloadId);
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), request.Instance.Url);
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
if (!generalConfig.SearchEnabled)
await _messageBus.Publish(new DownloadHuntRequest<T>
{
return;
InstanceType = request.InstanceType,
Instance = request.Instance,
SearchItem = request.SearchItem,
Record = request.Record
});
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is not HttpStatusCode.NotFound)
{
throw;
}
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// prevent tracker spamming
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay));
throw new Exception($"Item might have already been deleted by your {request.InstanceType} instance", exception);
}
finally
{

View File

@@ -27,6 +27,7 @@ public sealed class Striker : IStriker
{
if (maxStrikes is 0)
{
_logger.LogTrace("skip striking for {reason} | max strikes is 0 | {name}", strikeType, itemName);
return false;
}

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