Compare commits

...

18 Commits

Author SHA1 Message Date
Flaminel
44994d5b21 Fix Notifiarr channel id input (#267) 2025-08-04 22:07:33 +03:00
Flaminel
592fd2d846 Fix Malware Blocker renaming issue (#259) 2025-08-02 15:54:26 +03:00
Flaminel
e96be1fca2 Small general fixes (#257)
* renamed ContentBlocker into MalwareBlocker in the logs

* fixed "Delete Private" input description
2025-08-02 11:36:47 +03:00
Flaminel
ee44e2b5ac Rework sidebar navigation (#255) 2025-08-02 05:31:25 +03:00
Flaminel
323bfc4d2e added major and minor tags for Docker images 2025-08-01 19:51:10 +03:00
Flaminel
dca45585ca General frontend improvements (#252) 2025-08-01 19:45:01 +03:00
Flaminel
8b5918d221 Improve malware detection for known malware (#251) 2025-08-01 19:33:35 +03:00
Flaminel
9c227c1f59 add Cloudflare static assets 2025-08-01 18:37:45 +03:00
Flaminel
2ad4499a6f Fix DownloadCleaner failing when using multiple download clients (#248) 2025-07-31 22:20:01 +03:00
Flaminel
33a5bf9ab3 Add uTorrent support (#240) 2025-07-28 23:09:19 +03:00
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
124 changed files with 5277 additions and 1380 deletions

View File

@@ -29,6 +29,8 @@ jobs:
githubHeadRef=${{ env.githubHeadRef }}
latestDockerTag=""
versionDockerTag=""
majorVersionDockerTag=""
minorVersionDockerTag=""
version="0.0.1"
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
@@ -36,6 +38,12 @@ jobs:
latestDockerTag="latest"
versionDockerTag=${branch#v}
version=${branch#v}
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
# Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then
@@ -58,6 +66,12 @@ jobs:
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
fi
# set env vars
echo "branch=$branch" >> $GITHUB_ENV

36
.github/workflows/cloudflare-pages.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
paths:
- 'Cloudflare/**'
- 'blacklist'
- 'blacklist_permissive'
- 'whitelist'
- 'whitelist_with_subtitles'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy root static files to Cloudflare static directory
run: |
cp blacklist Cloudflare/static/
cp blacklist_permissive Cloudflare/static/
cp whitelist Cloudflare/static/
cp whitelist_with_subtitles Cloudflare/static/
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "Cloudflare"
command: pages deploy . --project-name=cleanuparr

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

3
Cloudflare/_headers Normal file
View File

@@ -0,0 +1,3 @@
# Cache static files for 5 minutes
/static/*
Cache-Control: public, max-age=300, s-maxage=300

View File

@@ -0,0 +1,2 @@
thepirateheaven.org
RARBG.work

View File

@@ -25,21 +25,24 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
## 🎯 Supported Applications
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
- **µTorrent**
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
## 🚀 Quick Start
@@ -55,7 +58,7 @@ docker run -d --name cleanuparr \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface

View File

@@ -495,7 +495,7 @@ public class ConfigurationController : ControllerBase
// Validate cron expression if present
if (!string.IsNullOrEmpty(newConfig.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.ContentBlocker);
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.MalwareBlocker);
}
// Get existing config
@@ -513,7 +513,7 @@ public class ConfigurationController : ControllerBase
await _dataContext.SaveChangesAsync();
// Update the scheduler based on configuration changes
await UpdateJobSchedule(oldConfig, JobType.ContentBlocker);
await UpdateJobSchedule(oldConfig, JobType.MalwareBlocker);
return Ok(new { Message = "ContentBlocker configuration updated successfully" });
}

View File

@@ -27,7 +27,7 @@ public static class LoggingDI
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
// Determine job name padding
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding

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<MalwareBlocker>()
.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);
@@ -128,12 +132,12 @@ public class BackgroundJobManager : IHostedService
CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
}
}
@@ -186,7 +190,7 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}

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

@@ -20,12 +20,12 @@ using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Application.Features.ContentBlocker;
public sealed class ContentBlocker : GenericHandler
public sealed class MalwareBlocker : GenericHandler
{
private readonly BlocklistProvider _blocklistProvider;
public ContentBlocker(
ILogger<ContentBlocker> logger,
public MalwareBlocker(
ILogger<MalwareBlocker> logger,
DataContext dataContext,
IMemoryCache cache,
IBus messageBus,
@@ -66,27 +66,27 @@ public sealed class ContentBlocker : GenericHandler
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled)
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
}
if (config.Radarr.Enabled)
if (config.Radarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
}
if (config.Lidarr.Enabled)
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled)
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}

View File

@@ -61,8 +61,8 @@ public sealed class DownloadCleaner : GenericHandler
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
// Process each client separately
var allDownloads = new List<object>();
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();
foreach (var downloadService in downloadServices)
{
try
@@ -71,24 +71,24 @@ public sealed class DownloadCleaner : GenericHandler
var clientDownloads = await downloadService.GetSeedingDownloads();
if (clientDownloads?.Count > 0)
{
allDownloads.AddRange(clientDownloads);
downloadServiceToDownloadsMap[downloadService] = clientDownloads;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get seeding downloads from download client");
_logger.LogError(ex, "Failed to get seeding downloads from download client {clientName}", downloadService.ClientConfig.Name);
}
}
if (allDownloads.Count == 0)
if (downloadServiceToDownloadsMap.Count == 0)
{
_logger.LogDebug("no seeding downloads found");
return;
}
_logger.LogTrace("found {count} seeding downloads", allDownloads.Count);
var totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count);
_logger.LogTrace("found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
// List<object>? downloadsToChangeCategory = null;
List<Tuple<IDownloadService, List<object>>> downloadServiceWithDownloads = [];
if (isUnlinkedEnabled)
@@ -102,24 +102,23 @@ public sealed class DownloadCleaner : GenericHandler
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create category for download client");
_logger.LogError(ex, "Failed to create category for download client {clientName}", downloadService.ClientConfig.Name);
}
}
// Get downloads to change category
foreach (var downloadService in downloadServices)
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
try
{
var clientDownloads = downloadService.FilterDownloadsToChangeCategoryAsync(allDownloads, config.UnlinkedCategories);
if (clientDownloads?.Count > 0)
var downloadsToChangeCategory = downloadService.FilterDownloadsToChangeCategoryAsync(clientDownloads, config.UnlinkedCategories);
if (downloadsToChangeCategory?.Count > 0)
{
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToChangeCategory));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to filter downloads for category change");
_logger.LogError(ex, "Failed to filter downloads for category change for download client {clientName}", downloadService.ClientConfig.Name);
}
}
}
@@ -158,16 +157,15 @@ public sealed class DownloadCleaner : GenericHandler
return;
}
// Get downloads to clean
downloadServiceWithDownloads = [];
foreach (var downloadService in downloadServices)
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
try
{
var clientDownloads = downloadService.FilterDownloadsToBeCleanedAsync(allDownloads, config.Categories);
if (clientDownloads?.Count > 0)
var downloadsToClean = downloadService.FilterDownloadsToBeCleanedAsync(clientDownloads, config.Categories);
if (downloadsToClean?.Count > 0)
{
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, clientDownloads));
downloadServiceWithDownloads.Add(Tuple.Create(downloadService, downloadsToClean));
}
}
catch (Exception ex)
@@ -176,9 +174,6 @@ public sealed class DownloadCleaner : GenericHandler
}
}
// release unused objects
allDownloads = null;
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
// Process cleaning for each client

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

@@ -0,0 +1,69 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Request;
/// <summary>
/// Represents a request to the µTorrent Web UI API
/// </summary>
public sealed class UTorrentRequest
{
/// <summary>
/// The API action to perform
/// </summary>
public string Action { get; set; } = string.Empty;
/// <summary>
/// Authentication token (required for CSRF protection)
/// </summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// Additional parameters for the request
/// </summary>
public List<(string Name, string Value)> Parameters { get; set; } = new();
/// <summary>
/// Constructs the query string for the API call
/// </summary>
/// <returns>The complete query string including token and action</returns>
public string ToQueryString()
{
var queryParams = new List<string>
{
$"token={Token}",
Action
};
foreach (var param in Parameters)
{
queryParams.Add($"{Uri.EscapeDataString(param.Name)}={Uri.EscapeDataString(param.Value)}");
}
return string.Join("&", queryParams);
}
/// <summary>
/// Creates a new request with the specified action
/// </summary>
/// <param name="action">The API action</param>
/// <param name="token">Authentication token</param>
/// <returns>A new UTorrentRequest instance</returns>
public static UTorrentRequest Create(string action, string token)
{
return new UTorrentRequest
{
Action = action,
Token = token
};
}
/// <summary>
/// Adds a parameter to the request
/// </summary>
/// <param name="key">Parameter name</param>
/// <param name="value">Parameter value</param>
/// <returns>This instance for method chaining</returns>
public UTorrentRequest WithParameter(string key, string value)
{
Parameters.Add((key, value));
return this;
}
}

View File

@@ -0,0 +1,28 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for file list API calls
/// Replaces the generic UTorrentResponse<T> for file listings
/// </summary>
public sealed class FileListResponse
{
/// <summary>
/// Raw file data from the API
/// </summary>
[JsonProperty(PropertyName = "files")]
public object[]? FilesRaw { get; set; }
/// <summary>
/// Torrent hash for which files are listed
/// </summary>
[JsonIgnore]
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Parsed files as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentFile> Files { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for label list API calls
/// Replaces the generic UTorrentResponse<T> for label listings
/// </summary>
public sealed class LabelListResponse
{
/// <summary>
/// Raw label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent properties API calls
/// Replaces the generic UTorrentResponse<T> for properties retrieval
/// </summary>
public sealed class PropertiesResponse
{
/// <summary>
/// Raw properties data from the API
/// </summary>
[JsonProperty(PropertyName = "props")]
public object[]? PropertiesRaw { get; set; }
/// <summary>
/// Parsed properties as strongly-typed object
/// </summary>
[JsonIgnore]
public UTorrentProperties Properties { get; set; } = new();
}

View File

@@ -0,0 +1,40 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Specific response type for torrent list API calls
/// Replaces the generic UTorrentResponse<T> for torrent listings
/// </summary>
public sealed class TorrentListResponse
{
/// <summary>
/// µTorrent build number
/// </summary>
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
/// <summary>
/// List of torrent data from the API
/// </summary>
[JsonProperty(PropertyName = "torrents")]
public object[][]? TorrentsRaw { get; set; }
/// <summary>
/// Label data from the API
/// </summary>
[JsonProperty(PropertyName = "label")]
public object[][]? LabelsRaw { get; set; }
/// <summary>
/// Parsed torrents as strongly-typed objects
/// </summary>
[JsonIgnore]
public List<UTorrentItem> Torrents { get; set; } = new();
/// <summary>
/// Parsed labels as string list
/// </summary>
[JsonIgnore]
public List<string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,18 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a file within a torrent from µTorrent Web UI API
/// Based on the files array structure from the API documentation
/// </summary>
public sealed class UTorrentFile
{
public string Name { get; set; } = string.Empty;
public long Size { get; set; }
public long Downloaded { get; set; }
public int Priority { get; set; }
public int Index { get; set; }
}

View File

@@ -0,0 +1,181 @@
using Newtonsoft.Json;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents a torrent from µTorrent Web UI API
/// Based on the torrent array structure from the API documentation
/// </summary>
public sealed class UTorrentItem
{
/// <summary>
/// Torrent hash (index 0)
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Status bitfield (index 1)
/// </summary>
public int Status { get; set; }
/// <summary>
/// Torrent name (index 2)
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Total size in bytes (index 3)
/// </summary>
public long Size { get; set; }
/// <summary>
/// Progress in permille (1000 = 100%) (index 4)
/// </summary>
public int Progress { get; set; }
/// <summary>
/// Downloaded bytes (index 5)
/// </summary>
public long Downloaded { get; set; }
/// <summary>
/// Uploaded bytes (index 6)
/// </summary>
public long Uploaded { get; set; }
/// <summary>
/// Ratio * 1000 (index 7)
/// </summary>
public int RatioRaw { get; set; }
/// <summary>
/// Upload speed in bytes/sec (index 8)
/// </summary>
public int UploadSpeed { get; set; }
/// <summary>
/// Download speed in bytes/sec (index 9)
/// </summary>
public int DownloadSpeed { get; set; }
/// <summary>
/// ETA in seconds (index 10)
/// </summary>
public int ETA { get; set; }
/// <summary>
/// Label (index 11)
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Connected peers (index 12)
/// </summary>
public int PeersConnected { get; set; }
/// <summary>
/// Peers in swarm (index 13)
/// </summary>
public int PeersInSwarm { get; set; }
/// <summary>
/// Connected seeds (index 14)
/// </summary>
public int SeedsConnected { get; set; }
/// <summary>
/// Seeds in swarm (index 15)
/// </summary>
public int SeedsInSwarm { get; set; }
/// <summary>
/// Availability (index 16)
/// </summary>
public int Availability { get; set; }
/// <summary>
/// Queue order (index 17)
/// </summary>
public int QueueOrder { get; set; }
/// <summary>
/// Remaining bytes (index 18)
/// </summary>
public long Remaining { get; set; }
/// <summary>
/// Download URL (index 19)
/// </summary>
public string DownloadUrl { get; set; } = string.Empty;
/// <summary>
/// RSS feed URL (index 20)
/// </summary>
public string RssFeedUrl { get; set; } = string.Empty;
/// <summary>
/// Status message (index 21)
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Stream ID (index 22)
/// </summary>
public string StreamId { get; set; } = string.Empty;
/// <summary>
/// Date added as Unix timestamp (index 23)
/// </summary>
public long DateAdded { get; set; }
/// <summary>
/// Date completed as Unix timestamp (index 24)
/// </summary>
public long DateCompleted { get; set; }
/// <summary>
/// App update URL (index 25)
/// </summary>
public string AppUpdateUrl { get; set; } = string.Empty;
/// <summary>
/// Save path (index 26)
/// </summary>
public string SavePath { get; set; } = string.Empty;
/// <summary>
/// Calculated ratio value (RatioRaw / 1000.0)
/// </summary>
[JsonIgnore]
public double Ratio => RatioRaw / 1000.0;
/// <summary>
/// Progress as percentage (0.0 to 1.0)
/// </summary>
[JsonIgnore]
public double ProgressPercent => Progress / 1000.0;
/// <summary>
/// Date completed as DateTime (or null if not completed)
/// </summary>
[JsonIgnore]
public DateTime? DateCompletedDateTime =>
DateCompleted > 0 ? DateTimeOffset.FromUnixTimeSeconds(DateCompleted).DateTime : null;
/// <summary>
/// Seeding time in seconds (calculated from DateCompleted to now)
/// </summary>
[JsonIgnore]
public TimeSpan? SeedingTime
{
get
{
if (DateCompletedDateTime.HasValue)
{
return DateTime.UtcNow - DateCompletedDateTime.Value;
}
return null;
}
}
}

View File

@@ -0,0 +1,85 @@
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Represents torrent properties from µTorrent Web UI API getprops action
/// Based on the properties structure from the API documentation
/// </summary>
public sealed class UTorrentProperties
{
/// <summary>
/// Torrent hash
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// Trackers list (newlines are represented by \r\n)
/// </summary>
public string Trackers { get; set; } = string.Empty;
public List<string> TrackerList => Trackers
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
/// <summary>
/// Upload limit in bytes per second
/// </summary>
public int UploadLimit { get; set; }
/// <summary>
/// Download limit in bytes per second
/// </summary>
public int DownloadLimit { get; set; }
/// <summary>
/// Initial seeding / Super seeding
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SuperSeed { get; set; }
/// <summary>
/// Use DHT
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int Dht { get; set; }
/// <summary>
/// Use PEX (Peer Exchange)
/// -1 = Not allowed (indicates private torrent), 0 = Disabled, 1 = Enabled
/// </summary>
public int Pex { get; set; }
/// <summary>
/// Override queueing
/// -1 = Not allowed, 0 = Disabled, 1 = Enabled
/// </summary>
public int SeedOverride { get; set; }
/// <summary>
/// Seed ratio in per mils (1000 = 1.0 ratio)
/// </summary>
public int SeedRatio { get; set; }
/// <summary>
/// Seeding time in seconds
/// 0 = No minimum seeding time
/// </summary>
public int SeedTime { get; set; }
/// <summary>
/// Upload slots
/// </summary>
public int UploadSlots { get; set; }
/// <summary>
/// Whether this torrent is private (based on PEX value)
/// Private torrents have PEX = -1 (not allowed)
/// </summary>
public bool IsPrivate => Pex == -1;
/// <summary>
/// Calculated seed ratio value (SeedRatio / 1000.0)
/// </summary>
public double SeedRatioValue => SeedRatio / 1000.0;
}

View File

@@ -0,0 +1,61 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Domain.Entities.UTorrent.Response;
/// <summary>
/// Base response wrapper for µTorrent Web UI API calls
/// </summary>
public sealed record UTorrentResponse<T>
{
[JsonProperty(PropertyName = "build")]
public int Build { get; set; }
[JsonProperty(PropertyName = "label")]
public object[][]? Labels { get; set; }
[JsonProperty(PropertyName = "torrents")]
public T? Torrents { get; set; }
[JsonProperty(PropertyName = "torrentp")]
public object[]? TorrentProperties { get; set; }
[JsonProperty(PropertyName = "files")]
public object[]? FilesDto { get; set; }
[JsonIgnore]
public List<UTorrentFile>? Files
{
get
{
if (FilesDto is null || FilesDto.Length < 2)
{
return null;
}
var files = new List<UTorrentFile>();
if (FilesDto[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
var fileTokenArray = (JArray)jToken;
var fileArray = fileTokenArray.ToObject<object[]>() ?? [];
files.Add(new UTorrentFile
{
Name = fileArray[0].ToString() ?? string.Empty,
Size = Convert.ToInt64(fileArray[1]),
Downloaded = Convert.ToInt64(fileArray[2]),
Priority = Convert.ToInt32(fileArray[3]),
});
}
}
return files;
}
}
[JsonProperty(PropertyName = "props")]
public UTorrentProperties[]? Properties { get; set; }
}

View File

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

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public class DelugeClientException : Exception
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLoginException : DelugeClientException
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Deluge.Exceptions;
namespace Cleanuparr.Domain.Exceptions;
public sealed class DelugeLogoutException : DelugeClientException
{

View File

@@ -0,0 +1,15 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent authentication fails
/// </summary>
public class UTorrentAuthenticationException : UTorrentException
{
public UTorrentAuthenticationException(string message) : base(message)
{
}
public UTorrentAuthenticationException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class UTorrentException : Exception
{
public UTorrentException(string message) : base(message)
{
}
public UTorrentException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,22 @@
namespace Cleanuparr.Domain.Exceptions;
/// <summary>
/// Exception thrown when µTorrent response parsing fails
/// </summary>
public class UTorrentParsingException : UTorrentException
{
/// <summary>
/// The raw response that failed to parse
/// </summary>
public string RawResponse { get; }
public UTorrentParsingException(string message, string rawResponse) : base(message)
{
RawResponse = rawResponse;
}
public UTorrentParsingException(string message, string rawResponse, Exception innerException) : base(message, innerException)
{
RawResponse = rawResponse;
}
}

View File

@@ -16,6 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />

View File

@@ -0,0 +1,266 @@
using System.Net;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Newtonsoft.Json;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.DownloadClient;
public class UTorrentClientTests
{
private readonly UTorrentClient _client;
private readonly Mock<HttpMessageHandler> _mockHttpHandler;
private readonly DownloadClientConfig _config;
private readonly Mock<IUTorrentAuthenticator> _mockAuthenticator;
private readonly Mock<IUTorrentHttpService> _mockHttpService;
private readonly Mock<IUTorrentResponseParser> _mockResponseParser;
private readonly Mock<ILogger<UTorrentClient>> _mockLogger;
public UTorrentClientTests()
{
_mockHttpHandler = new Mock<HttpMessageHandler>();
_mockAuthenticator = new Mock<IUTorrentAuthenticator>();
_mockHttpService = new Mock<IUTorrentHttpService>();
_mockResponseParser = new Mock<IUTorrentResponseParser>();
_mockLogger = new Mock<ILogger<UTorrentClient>>();
_config = new DownloadClientConfig
{
Name = "test",
Type = DownloadClientType.Torrent,
TypeName = DownloadClientTypeName.uTorrent,
Host = new Uri("http://localhost:8080"),
Username = "admin",
Password = "password"
};
_client = new UTorrentClient(
_config,
_mockAuthenticator.Object,
_mockHttpService.Object,
_mockResponseParser.Object,
_mockLogger.Object
);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldDeserializeMixedArrayCorrectly()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED", // Hash (string)
new object[] // File 1
{
"test name",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 2
{
"Dir1/Dir11/test11.zipx",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
},
new object[] // File 3
{
"Dir1/sample.txt",
2604L,
0L,
2,
0,
1,
false,
-1,
-1,
-1,
-1,
-1,
0
}
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Equal(3, files.Count);
Assert.Equal("test name", files[0].Name);
Assert.Equal(2604L, files[0].Size);
Assert.Equal(0L, files[0].Downloaded);
Assert.Equal(2, files[0].Priority);
Assert.Equal(0, files[0].Index);
Assert.Equal("Dir1/Dir11/test11.zipx", files[1].Name);
Assert.Equal(2604L, files[1].Size);
Assert.Equal(0L, files[1].Downloaded);
Assert.Equal(2, files[1].Priority);
Assert.Equal(1, files[1].Index);
Assert.Equal("Dir1/sample.txt", files[2].Name);
Assert.Equal(2604L, files[2].Size);
Assert.Equal(0L, files[2].Downloaded);
Assert.Equal(2, files[2].Priority);
Assert.Equal(2, files[2].Index);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleEmptyResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = new object[]
{
"F0616FB199B78254474AF6D72705177E71D713ED" // Only hash, no files
}
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
[Fact]
public async Task GetTorrentFilesAsync_ShouldHandleNullResponse()
{
// Arrange
var mockResponse = new UTorrentResponse<object>
{
Build = 30470,
FilesDto = null
};
// Mock the token request
var tokenResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("<div id='token'>test-token</div>")
};
tokenResponse.Headers.Add("Set-Cookie", "GUID=test-guid; path=/");
// Mock the files request
var filesResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonConvert.SerializeObject(mockResponse))
};
// Setup mock to return different responses based on URL
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("token.html")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(tokenResponse);
_mockHttpHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.AbsolutePath.Contains("gui") && req.RequestUri.Query.Contains("action=getfiles")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(filesResponse);
// Act
var files = await _client.GetTorrentFilesAsync("test-hash");
// Assert
Assert.NotNull(files);
Assert.Empty(files);
}
}

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,22 +19,25 @@ 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();
private static DateTime _lastLoadTime = DateTime.MinValue;
private const int LoadIntervalHours = 4;
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
private const int DefaultLoadIntervalHours = 4;
private const int FastLoadIntervalMinutes = 5;
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
private const string MalwareListKey = "MALWARE_PATTERNS";
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,18 +46,12 @@ 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()
.FirstAsync();
bool shouldReload = false;
if (_lastLoadTime.AddHours(LoadIntervalHours) < DateTime.UtcNow)
{
shouldReload = true;
_lastLoadTime = DateTime.UtcNow;
}
if (!contentBlockerConfig.Enabled)
{
@@ -64,59 +61,77 @@ public sealed class BlocklistProvider
// Check and update Sonarr blocklist if needed
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
var sonarrInterval = GetLoadInterval(contentBlockerConfig.Sonarr.BlocklistPath);
var sonarrIdentifier = $"Sonarr_{contentBlockerConfig.Sonarr.BlocklistPath}";
if (ShouldReloadBlocklist(sonarrIdentifier, sonarrInterval) || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
{
_logger.LogDebug("Loading Sonarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
_configHashes[InstanceType.Sonarr] = sonarrHash;
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Radarr blocklist if needed
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
var radarrInterval = GetLoadInterval(contentBlockerConfig.Radarr.BlocklistPath);
var radarrIdentifier = $"Radarr_{contentBlockerConfig.Radarr.BlocklistPath}";
if (ShouldReloadBlocklist(radarrIdentifier, radarrInterval) || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
{
_logger.LogDebug("Loading Radarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
_configHashes[InstanceType.Radarr] = radarrHash;
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
var lidarrInterval = GetLoadInterval(contentBlockerConfig.Lidarr.BlocklistPath);
var lidarrIdentifier = $"Lidarr_{contentBlockerConfig.Lidarr.BlocklistPath}";
if (ShouldReloadBlocklist(lidarrIdentifier, lidarrInterval) || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
{
_logger.LogDebug("Loading Lidarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
_configHashes[InstanceType.Lidarr] = lidarrHash;
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
// Check and update Readarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
var readarrInterval = GetLoadInterval(contentBlockerConfig.Readarr.BlocklistPath);
var readarrIdentifier = $"Readarr_{contentBlockerConfig.Readarr.BlocklistPath}";
if (ShouldReloadBlocklist(readarrIdentifier, readarrInterval) || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
var whisparrInterval = GetLoadInterval(contentBlockerConfig.Whisparr.BlocklistPath);
var whisparrIdentifier = $"Whisparr_{contentBlockerConfig.Whisparr.BlocklistPath}";
if (ShouldReloadBlocklist(whisparrIdentifier, whisparrInterval) || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync();
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
@@ -153,6 +168,77 @@ public sealed class BlocklistProvider
return regexes ?? [];
}
public ConcurrentBag<string> GetMalwarePatterns()
{
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
return patterns ?? [];
}
private TimeSpan GetLoadInterval(string? path)
{
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
{
if (uri.Host.Equals("cleanuparr.pages.dev", StringComparison.OrdinalIgnoreCase))
{
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
}
return TimeSpan.FromHours(DefaultLoadIntervalHours);
}
// If fast load interval for local files
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
}
private bool ShouldReloadBlocklist(string identifier, TimeSpan interval)
{
if (!_lastLoadTimes.TryGetValue(identifier, out DateTime lastLoad))
{
return true;
}
return DateTime.UtcNow - lastLoad >= interval;
}
private async Task LoadMalwarePatternsAsync()
{
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
{
return;
}
try
{
_logger.LogDebug("Loading malware patterns");
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
ConcurrentBag<string> patterns = [];
Parallel.ForEach(filePatterns, options, pattern =>
{
patterns.Add(pattern);
});
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
}
}
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
{

View File

@@ -20,6 +20,16 @@ public class FilenameEvaluator : IFilenameEvaluator
return IsValidAgainstPatterns(filename, type, patterns) && IsValidAgainstRegexes(filename, type, regexes);
}
public bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns)
{
if (malwarePatterns.Count is 0)
{
return false;
}
return malwarePatterns.Any(pattern => filename.Contains(pattern, StringComparison.InvariantCultureIgnoreCase));
}
private static bool IsValidAgainstPatterns(string filename, BlocklistType type, ConcurrentBag<string> patterns)
{
if (patterns.Count is 0)

View File

@@ -7,4 +7,6 @@ namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
public interface IFilenameEvaluator
{
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
}

View File

@@ -4,7 +4,6 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.Extensions;
using Cleanuparr.Persistence.Models.Configuration;
using Data.Models.Deluge.Exceptions;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;

View File

@@ -69,6 +69,7 @@ public partial class DelugeService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
ProcessFiles(contents.Contents, (name, file) =>
{
@@ -80,7 +81,7 @@ public partial class DelugeService
return;
}
if (IsDefinitelyMalware(name))
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;

View File

@@ -100,16 +100,6 @@ 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

@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
@@ -20,12 +21,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,9 +47,10 @@ public sealed class DownloadServiceFactory
return downloadClientConfig.TypeName switch
{
DownloadClientTypeName.QBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -114,4 +117,26 @@ public sealed class DownloadServiceFactory
return service;
}
private UTorrentService CreateUTorrentService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<UTorrentService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
// Create the UTorrentService instance
UTorrentService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory
);
return service;
}
}

View File

@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
_logger.LogError("Failed to find torrent properties {name}", download.Name);
return result;
}
@@ -60,9 +60,9 @@ public partial class QBitService
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
if (files is null)
if (files?.Count is null or 0)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}
@@ -74,6 +74,7 @@ public partial class QBitService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
foreach (TorrentContent file in files)
{
@@ -85,7 +86,7 @@ public partial class QBitService
totalFiles++;
if (IsDefinitelyMalware(file.Name))
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;

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();
@@ -97,7 +97,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
_logger.LogError("Failed to find torrent properties | {name}", download.Name);
return;
}

View File

@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
_logger.LogError("Failed to find torrent properties for {name}", download.Name);
return result;
}
@@ -89,32 +89,32 @@ public partial class QBitService
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogDebug("skip slow check | max strikes is 0 | {name}", download.Name);
_logger.LogTrace("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);
_logger.LogTrace("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);
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -139,7 +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);
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", torrent.Name);
return (false, DeleteReason.None);
}
@@ -147,7 +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);
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
return (false, DeleteReason.None);
}
@@ -156,7 +156,7 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | download is private | {name}", torrent.Name);
}
else
{
@@ -175,7 +175,7 @@ public partial class QBitService
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
}
_logger.LogDebug("skip stalled check | download is not stalled | {name}", torrent.Name);
_logger.LogTrace("skip stalled check | download is not stalled | {name}", torrent.Name);
return (false, DeleteReason.None);
}
}

View File

@@ -57,6 +57,7 @@ public partial class TransmissionService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < download.Files.Length; i++)
{
@@ -68,7 +69,7 @@ public partial class TransmissionService
totalFiles++;
if (IsDefinitelyMalware(download.Files[i].Name))
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;

View File

@@ -0,0 +1,77 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
/// <summary>
/// Extension methods for µTorrent entities and status checking
/// </summary>
public static class UTorrentExtensions
{
/// <summary>
/// Checks if the torrent is currently seeding
/// </summary>
public static bool IsSeeding(this UTorrentItem item)
{
return IsDownloading(item.Status) && item.DateCompleted > 0;
}
/// <summary>
/// Checks if the torrent is currently downloading
/// </summary>
public static bool IsDownloading(this UTorrentItem item)
{
return IsDownloading(item.Status);
}
/// <summary>
/// Checks if the status indicates downloading
/// </summary>
public static bool IsDownloading(int status)
{
return (status & UTorrentStatus.Started) != 0 &&
(status & UTorrentStatus.Checked) != 0 &&
(status & UTorrentStatus.Error) == 0;
}
/// <summary>
/// Checks if a torrent should be ignored based on the ignored patterns
/// </summary>
public static bool ShouldIgnore(this UTorrentItem download, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (download.Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (download.Label.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
public static bool ShouldIgnore(this string tracker, IReadOnlyList<string> ignoredDownloads)
{
string? trackerUrl = UriService.GetDomain(tracker);
if (trackerUrl is null)
{
return false;
}
foreach (string value in ignoredDownloads)
{
if (trackerUrl.EndsWith(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,46 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for µTorrent authentication management with caching support
/// Handles token management and session state with multi-client support
/// </summary>
public interface IUTorrentAuthenticator
{
/// <summary>
/// Ensures that the client is authenticated and the token is valid
/// </summary>
/// <returns>True if authentication is successful</returns>
Task<bool> EnsureAuthenticatedAsync();
/// <summary>
/// Gets a valid authentication token, refreshing if necessary
/// </summary>
/// <returns>Valid authentication token</returns>
Task<string> GetValidTokenAsync();
/// <summary>
/// Gets a valid GUID cookie, refreshing if necessary
/// </summary>
/// <returns>Valid GUID cookie</returns>
Task<string> GetValidGuidCookieAsync();
/// <summary>
/// Forces a refresh of the authentication session
/// </summary>
Task RefreshSessionAsync();
/// <summary>
/// Invalidates the cached authentication session
/// </summary>
Task InvalidateSessionAsync();
/// <summary>
/// Gets whether the client is currently authenticated
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// Gets the GUID cookie for the current session
/// </summary>
string GuidCookie { get; }
}

View File

@@ -0,0 +1,24 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for raw HTTP communication with µTorrent Web UI API
/// Handles low-level HTTP requests and authentication token retrieval
/// </summary>
public interface IUTorrentHttpService
{
/// <summary>
/// Sends a raw HTTP request to the µTorrent API
/// </summary>
/// <param name="request">The request to send</param>
/// <param name="guidCookie">The GUID cookie for authentication</param>
/// <returns>Raw JSON response from the API</returns>
Task<string> SendRawRequestAsync(UTorrentRequest request, string guidCookie);
/// <summary>
/// Retrieves authentication token and GUID cookie from µTorrent
/// </summary>
/// <returns>Tuple containing the authentication token and GUID cookie</returns>
Task<(string token, string guidCookie)> GetTokenAndCookieAsync();
}

View File

@@ -0,0 +1,38 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for parsing µTorrent API responses
/// Provides endpoint-specific parsing methods for different response types
/// </summary>
public interface IUTorrentResponseParser
{
/// <summary>
/// Parses a torrent list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed torrent list response</returns>
TorrentListResponse ParseTorrentList(string json);
/// <summary>
/// Parses a file list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed file list response</returns>
FileListResponse ParseFileList(string json);
/// <summary>
/// Parses a properties response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed properties response</returns>
PropertiesResponse ParseProperties(string json);
/// <summary>
/// Parses a label list response from JSON
/// </summary>
/// <param name="json">Raw JSON response from the API</param>
/// <returns>Parsed label list response</returns>
LabelListResponse ParseLabelList(string json);
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Interface for µTorrent download service
/// </summary>
public interface IUTorrentService : IDownloadService
{
}

View File

@@ -0,0 +1,16 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Represents cached authentication data for a µTorrent client instance
/// </summary>
public sealed class UTorrentAuthCache
{
public string AuthToken { get; init; } = string.Empty;
public string GuidCookie { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public DateTime ExpiresAt { get; init; }
public bool IsValid => DateTime.UtcNow < ExpiresAt &&
!string.IsNullOrEmpty(AuthToken) &&
!string.IsNullOrEmpty(GuidCookie);
}

View File

@@ -0,0 +1,237 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of µTorrent authentication management with IMemoryCache-based token sharing
/// Handles concurrent authentication requests and provides thread-safe token caching per client
/// </summary>
public class UTorrentAuthenticator : IUTorrentAuthenticator
{
private readonly IMemoryCache _cache;
private readonly IUTorrentHttpService _httpService;
private readonly DownloadClientConfig _config;
private readonly ILogger<UTorrentAuthenticator> _logger;
// Use a static concurrent dictionary to ensure same client instances share the same semaphore
// This prevents multiple instances of the same client from authenticating simultaneously
private readonly SemaphoreSlim _authSemaphore;
private readonly string _clientKey;
// Cache configuration - conservative timings to avoid token expiration issues
private static readonly TimeSpan TokenExpiryDuration = TimeSpan.FromMinutes(20);
private static readonly TimeSpan CacheAbsoluteExpiration = TimeSpan.FromMinutes(25);
public UTorrentAuthenticator(
IMemoryCache cache,
IUTorrentHttpService httpService,
DownloadClientConfig config,
ILogger<UTorrentAuthenticator> logger)
{
_cache = cache;
_httpService = httpService;
_config = config;
_logger = logger;
// Create unique client key based on connection details
// This ensures different µTorrent instances don't share auth tokens
_clientKey = GetClientKey();
// Get or create semaphore for this specific client configuration
if (_cache.TryGetValue<SemaphoreSlim>(_clientKey, out var authSemaphore) && authSemaphore is not null)
{
_authSemaphore = authSemaphore;
return;
}
_authSemaphore = new SemaphoreSlim(1, 1);
_cache.Set(_clientKey, _authSemaphore, Constants.DefaultCacheEntryOptions);
}
/// <inheritdoc/>
public bool IsAuthenticated
{
get
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
return _cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true;
}
}
/// <inheritdoc/>
public string GuidCookie
{
get
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.GuidCookie;
}
return string.Empty;
}
}
/// <inheritdoc/>
public async Task<bool> EnsureAuthenticatedAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
// Fast path: Check if we have valid cached auth
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return true;
}
// Slow path: Need to refresh authentication with concurrency control
return await RefreshAuthenticationWithLockAsync();
}
/// <inheritdoc/>
public async Task<string> GetValidTokenAsync()
{
if (!await EnsureAuthenticatedAsync())
{
throw new UTorrentAuthenticationException($"Failed to authenticate with µTorrent client '{_config.Name}'");
}
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.AuthToken;
}
throw new UTorrentAuthenticationException($"Authentication token not available for µTorrent client '{_config.Name}'");
}
/// <inheritdoc/>
public async Task<string> GetValidGuidCookieAsync()
{
if (!await EnsureAuthenticatedAsync())
{
throw new UTorrentAuthenticationException($"Failed to authenticate with µTorrent client '{_config.Name}'");
}
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return cachedAuth.GuidCookie;
}
throw new UTorrentAuthenticationException($"GUID cookie not available for µTorrent client '{_config.Name}'");
}
/// <inheritdoc/>
public async Task RefreshSessionAsync()
{
const int maxRetries = 3;
var retryCount = 0;
var backoffDelay = TimeSpan.FromMilliseconds(500);
while (retryCount < maxRetries)
{
try
{
var (token, guidCookie) = await _httpService.GetTokenAndCookieAsync();
var authCache = new UTorrentAuthCache
{
AuthToken = token,
GuidCookie = guidCookie,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(TokenExpiryDuration)
};
// Cache with both sliding and absolute expiration
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = CacheAbsoluteExpiration,
SlidingExpiration = TokenExpiryDuration,
Priority = CacheItemPriority.High
};
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
_cache.Set(cacheKey, authCache, cacheOptions);
return;
}
catch (Exception ex) when (retryCount < maxRetries - 1)
{
retryCount++;
_logger.LogWarning(ex, "Authentication attempt {Attempt} failed for µTorrent client '{ClientName}', retrying in {Delay}ms",
retryCount, _config.Name, backoffDelay.TotalMilliseconds);
await Task.Delay(backoffDelay);
backoffDelay = TimeSpan.FromMilliseconds(backoffDelay.TotalMilliseconds * 1.5); // Exponential backoff
}
catch (Exception ex)
{
// Invalidate any existing cache entry on failure
await InvalidateSessionAsync();
throw new UTorrentAuthenticationException($"Failed to refresh authentication session after {maxRetries} attempts: {ex.Message}", ex);
}
}
}
/// <inheritdoc/>
public async Task InvalidateSessionAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
_cache.Remove(cacheKey);
await Task.CompletedTask;
}
/// <summary>
/// Refreshes authentication with concurrency control to prevent multiple simultaneous auth requests
/// </summary>
private async Task<bool> RefreshAuthenticationWithLockAsync()
{
var cacheKey = CacheKeys.UTorrent.GetAuthTokenKey(_clientKey);
// Wait for our turn to authenticate (per client instance)
await _authSemaphore.WaitAsync();
try
{
// Double-check: another thread might have refreshed while we were waiting
if (_cache.TryGetValue(cacheKey, out UTorrentAuthCache? cachedAuth) &&
cachedAuth?.IsValid == true)
{
return true;
}
// Actually refresh the authentication
await RefreshSessionAsync();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh authentication for µTorrent client '{ClientName}'", _config.Name);
return false;
}
finally
{
_authSemaphore.Release();
}
}
/// <summary>
/// Creates a unique client key based on connection details
/// This ensures different µTorrent instances don't share auth tokens
/// </summary>
private string GetClientKey()
{
return _config.Url.ToString();
}
}

View File

@@ -0,0 +1,280 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public sealed class UTorrentClient
{
private readonly DownloadClientConfig _config;
private readonly IUTorrentAuthenticator _authenticator;
private readonly IUTorrentHttpService _httpService;
private readonly IUTorrentResponseParser _responseParser;
private readonly ILogger<UTorrentClient> _logger;
public UTorrentClient(
DownloadClientConfig config,
IUTorrentAuthenticator authenticator,
IUTorrentHttpService httpService,
IUTorrentResponseParser responseParser,
ILogger<UTorrentClient> logger
)
{
_config = config;
_authenticator = authenticator;
_httpService = httpService;
_responseParser = responseParser;
_logger = logger;
}
/// <summary>
/// Authenticates with µTorrent and retrieves the authentication token
/// </summary>
/// <returns>True if authentication was successful</returns>
public async Task<bool> LoginAsync()
{
try
{
// Use the cache-aware authentication
var token = await _authenticator.GetValidTokenAsync();
return !string.IsNullOrEmpty(token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Login failed for µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to authenticate with µTorrent: {ex.Message}", ex);
}
}
/// <summary>
/// Tests the authentication and basic API connectivity
/// </summary>
/// <returns>True if authentication and basic API call works</returns>
public async Task<bool> TestConnectionAsync()
{
try
{
var torrents = await GetTorrentsAsync();
return true; // If we can get torrents, authentication is working
}
catch
{
return false;
}
}
/// <summary>
/// Gets all torrents from µTorrent
/// </summary>
/// <returns>List of torrents</returns>
public async Task<List<UTorrentItem>> GetTorrentsAsync()
{
try
{
var request = UTorrentRequestFactory.CreateTorrentListRequest();
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseTorrentList(json);
return response.Torrents;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrents from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to get torrents: {ex.Message}", ex);
}
}
/// <summary>
/// Gets a specific torrent by hash
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>The torrent or null if not found</returns>
public async Task<UTorrentItem?> GetTorrentAsync(string hash)
{
try
{
var torrents = await GetTorrentsAsync();
return torrents.FirstOrDefault(t =>
string.Equals(t.Hash, hash, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets files for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>List of files in the torrent</returns>
public async Task<List<UTorrentFile>?> GetTorrentFilesAsync(string hash)
{
try
{
var request = UTorrentRequestFactory.CreateFileListRequest(hash);
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseFileList(json);
return response.Files;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get files for torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get files for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets torrent properties including private/public status
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>UTorrentProperties object or null if not found</returns>
public async Task<UTorrentProperties> GetTorrentPropertiesAsync(string hash)
{
try
{
var request = UTorrentRequestFactory.CreatePropertiesRequest(hash);
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseProperties(json);
return response.Properties;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get properties for torrent {Hash} from µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to get properties for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Gets all labels from µTorrent
/// </summary>
/// <returns>List of label names</returns>
public async Task<List<string>> GetLabelsAsync()
{
try
{
var request = UTorrentRequestFactory.CreateLabelListRequest();
var json = await SendAuthenticatedRequestAsync(request);
var response = _responseParser.ParseLabelList(json);
return response.Labels;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get labels from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to get labels: {ex.Message}", ex);
}
}
/// <summary>
/// Sets the label for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="label">Label to set</param>
public async Task SetTorrentLabelAsync(string hash, string label)
{
try
{
var request = UTorrentRequestFactory.CreateSetLabelRequest(hash, label);
await SendAuthenticatedRequestAsync(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set label '{Label}' for torrent {Hash} in µTorrent client '{ClientName}'", label, hash, _config.Name);
throw new UTorrentException($"Failed to set label '{label}' for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Sets file priorities for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndexes">Index of the file to set priority for</param>
/// <param name="priority">File priority (0=skip, 1=low, 2=normal, 3=high)</param>
public async Task SetFilesPriorityAsync(string hash, List<int> fileIndexes, int priority)
{
try
{
var request = UTorrentRequestFactory.CreateSetFilePrioritiesRequest(hash, fileIndexes, priority);
await SendAuthenticatedRequestAsync(request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set file priority for torrent {Hash} in µTorrent client '{ClientName}'", hash, _config.Name);
throw new UTorrentException($"Failed to set file priority for torrent {hash}: {ex.Message}", ex);
}
}
/// <summary>
/// Removes torrents from µTorrent
/// </summary>
/// <param name="hashes">List of torrent hashes to remove</param>
public async Task RemoveTorrentsAsync(List<string> hashes)
{
try
{
foreach (var hash in hashes)
{
var request = UTorrentRequestFactory.CreateRemoveTorrentWithDataRequest(hash);
await SendAuthenticatedRequestAsync(request);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove torrents from µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentException($"Failed to remove torrents: {ex.Message}", ex);
}
}
/// <summary>
/// Creates a new label in µTorrent
/// </summary>
/// <param name="label">Label name to create</param>
public static async Task CreateLabel(string label)
{
// µTorrent doesn't have an explicit "create label" API
// Labels are created automatically when you assign them to a torrent
// So this is a no-op for µTorrent
await Task.CompletedTask;
}
/// <summary>
/// Sends an authenticated request to the µTorrent API
/// Handles automatic authentication and retry logic
/// </summary>
/// <param name="request">The request to send</param>
/// <returns>Raw JSON response from the API</returns>
private async Task<string> SendAuthenticatedRequestAsync(UTorrentRequest request)
{
try
{
// Get valid token and cookie from cache-aware authenticator
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (UTorrentAuthenticationException)
{
// On authentication failure, invalidate cache and retry once
try
{
await _authenticator.InvalidateSessionAsync();
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (Exception ex)
{
_logger.LogError(ex, "Authentication retry failed for µTorrent client '{ClientName}'", _config.Name);
throw new UTorrentAuthenticationException($"Authentication retry failed: {ex.Message}", ex);
}
}
}
}

View File

@@ -0,0 +1,181 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Request;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of HTTP service for µTorrent Web UI API communication
/// Handles low-level HTTP requests and authentication token retrieval
/// </summary>
public class UTorrentHttpService : IUTorrentHttpService
{
private readonly HttpClient _httpClient;
private readonly DownloadClientConfig _config;
private readonly ILogger<UTorrentHttpService> _logger;
// Regex pattern to extract token from µTorrent Web UI HTML
private static readonly Regex TokenRegex = new(@"<div[^>]*id=['""]token['""][^>]*>([^<]+)</div>",
RegexOptions.IgnoreCase);
public UTorrentHttpService(
HttpClient httpClient,
DownloadClientConfig config,
ILogger<UTorrentHttpService> logger)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
}
/// <inheritdoc/>
public async Task<string> SendRawRequestAsync(UTorrentRequest request, string guidCookie)
{
if (string.IsNullOrEmpty(guidCookie))
{
throw new UTorrentAuthenticationException("GUID cookie is required for API requests");
}
try
{
var queryString = request.ToQueryString();
UriBuilder uriBuilder = new UriBuilder(_config.Url)
{
Query = queryString
};
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/";
var httpRequest = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
httpRequest.Headers.Add("Cookie", guidCookie);
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}"));
httpRequest.Headers.Add("Authorization", $"Basic {credentials}");
var response = await _httpClient.SendAsync(httpRequest);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("UTorrent API request failed: {StatusCode} - {Content}", response.StatusCode, errorContent);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UTorrentAuthenticationException("Authentication failed - invalid credentials or token expired");
}
throw new UTorrentException($"HTTP request failed: {response.StatusCode} - {errorContent}");
}
var jsonResponse = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(jsonResponse))
{
throw new UTorrentException("Empty response received from µTorrent API");
}
return jsonResponse;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed for UTorrent API: {Action}", request.Action);
throw new UTorrentException($"HTTP request failed: {ex.Message}", ex);
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "HTTP request timeout for UTorrent API: {Action}", request.Action);
throw new UTorrentException($"HTTP request timeout: {ex.Message}", ex);
}
}
/// <inheritdoc/>
public async Task<(string token, string guidCookie)> GetTokenAndCookieAsync()
{
try
{
UriBuilder uriBuilder = new UriBuilder(_config.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/gui/token.html";
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_config.Username}:{_config.Password}"));
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
request.Headers.Add("Authorization", $"Basic {credentials}");
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to retrieve authentication token: {StatusCode} - {Content}",
response.StatusCode, errorContent);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UTorrentAuthenticationException("Authentication failed - check username and password");
}
throw new UTorrentException($"Token retrieval failed: {response.StatusCode} - {errorContent}");
}
var html = await response.Content.ReadAsStringAsync();
// Extract token from HTML
var tokenMatch = TokenRegex.Match(html);
if (!tokenMatch.Success)
{
_logger.LogError("Failed to extract token from HTML response: {Html}", html);
throw new UTorrentAuthenticationException("Failed to extract authentication token from response");
}
var token = tokenMatch.Groups[1].Value;
// Extract GUID from cookies
var guidCookie = ExtractGuidCookie(response.Headers);
if (string.IsNullOrEmpty(guidCookie))
{
throw new UTorrentAuthenticationException("Failed to extract GUID cookie from response");
}
return (token, guidCookie);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed while retrieving authentication token");
throw new UTorrentAuthenticationException($"Token retrieval failed: {ex.Message}", ex);
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "HTTP request timeout while retrieving authentication token");
throw new UTorrentAuthenticationException($"Token retrieval timeout: {ex.Message}", ex);
}
}
/// <summary>
/// Extracts the GUID cookie from HTTP response headers
/// </summary>
/// <param name="headers">HTTP response headers</param>
/// <returns>GUID cookie string or empty string if not found</returns>
private static string ExtractGuidCookie(System.Net.Http.Headers.HttpResponseHeaders headers)
{
if (!headers.TryGetValues("Set-Cookie", out var cookies))
{
return string.Empty;
}
foreach (var cookie in cookies)
{
if (cookie.Contains("GUID="))
{
return cookie.Split(';')[0]; // Get just the GUID part, ignore expires, path, etc.
}
}
return string.Empty;
}
}

View File

@@ -0,0 +1,96 @@
using Cleanuparr.Domain.Entities.UTorrent.Request;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Factory for creating type-safe UTorrent API requests
/// Provides specific methods for each supported API endpoint
/// </summary>
public static class UTorrentRequestFactory
{
/// <summary>
/// Creates a request to get the list of all torrents
/// </summary>
/// <returns>Request for torrent list API call</returns>
public static UTorrentRequest CreateTorrentListRequest()
{
return UTorrentRequest.Create("list=1", string.Empty);
}
/// <summary>
/// Creates a request to get files for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for file list API call</returns>
public static UTorrentRequest CreateFileListRequest(string hash)
{
return UTorrentRequest.Create("action=getfiles", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to get properties for a specific torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for properties API call</returns>
public static UTorrentRequest CreatePropertiesRequest(string hash)
{
return UTorrentRequest.Create("action=getprops", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to get all labels
/// </summary>
/// <returns>Request for label list API call</returns>
public static UTorrentRequest CreateLabelListRequest()
{
return UTorrentRequest.Create("list=1", string.Empty);
}
/// <summary>
/// Creates a request to remove a torrent and its data
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for remove torrent with data API call</returns>
public static UTorrentRequest CreateRemoveTorrentWithDataRequest(string hash)
{
return UTorrentRequest.Create("action=removedatatorrent", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to set file priorities for a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndexes"></param>
/// <param name="filePriority"></param>
/// <returns>Request for set file priorities API call</returns>
public static UTorrentRequest CreateSetFilePrioritiesRequest(string hash, List<int> fileIndexes, int filePriority)
{
var request = UTorrentRequest.Create("action=setprio", string.Empty)
.WithParameter("hash", hash)
.WithParameter("p", filePriority.ToString());
foreach (int fileIndex in fileIndexes)
{
request.WithParameter("f", fileIndex.ToString());
}
return request;
}
/// <summary>
/// Creates a request to set a torrent's label
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="label">Label to set</param>
/// <returns>Request for set label API call</returns>
public static UTorrentRequest CreateSetLabelRequest(string hash, string label)
{
return UTorrentRequest.Create("action=setprops", string.Empty)
.WithParameter("hash", hash)
.WithParameter("s", "label")
.WithParameter("v", label);
}
}

View File

@@ -0,0 +1,237 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// Implementation of µTorrent response parser
/// Handles endpoint-specific parsing of API responses with proper error handling
/// </summary>
public class UTorrentResponseParser : IUTorrentResponseParser
{
private readonly ILogger<UTorrentResponseParser> _logger;
public UTorrentResponseParser(ILogger<UTorrentResponseParser> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public TorrentListResponse ParseTorrentList(string json)
{
try
{
var response = JsonConvert.DeserializeObject<TorrentListResponse>(json);
if (response == null)
{
throw new UTorrentParsingException("Failed to deserialize torrent list response", json);
}
// Parse torrents
if (response.TorrentsRaw != null)
{
foreach (var data in response.TorrentsRaw)
{
if (data is { Length: >= 27 })
{
response.Torrents.Add(new UTorrentItem
{
Hash = data[0].ToString() ?? string.Empty,
Status = Convert.ToInt32(data[1]),
Name = data[2].ToString() ?? string.Empty,
Size = Convert.ToInt64(data[3]),
Progress = Convert.ToInt32(data[4]),
Downloaded = Convert.ToInt64(data[5]),
Uploaded = Convert.ToInt64(data[6]),
RatioRaw = Convert.ToInt32(data[7]),
UploadSpeed = Convert.ToInt32(data[8]),
DownloadSpeed = Convert.ToInt32(data[9]),
ETA = Convert.ToInt32(data[10]),
Label = data[11].ToString() ?? string.Empty,
PeersConnected = Convert.ToInt32(data[12]),
PeersInSwarm = Convert.ToInt32(data[13]),
SeedsConnected = Convert.ToInt32(data[14]),
SeedsInSwarm = Convert.ToInt32(data[15]),
Availability = Convert.ToInt32(data[16]),
QueueOrder = Convert.ToInt32(data[17]),
Remaining = Convert.ToInt64(data[18]),
DownloadUrl = data[19].ToString() ?? string.Empty,
RssFeedUrl = data[20].ToString() ?? string.Empty,
StatusMessage = data[21].ToString() ?? string.Empty,
StreamId = data[22].ToString() ?? string.Empty,
DateAdded = Convert.ToInt64(data[23]),
DateCompleted = Convert.ToInt64(data[24]),
AppUpdateUrl = data[25].ToString() ?? string.Empty,
SavePath = data[26].ToString() ?? string.Empty
});
}
}
}
// Parse labels
if (response.LabelsRaw != null)
{
foreach (var labelData in response.LabelsRaw)
{
if (labelData is { Length: > 0 })
{
var labelName = labelData[0].ToString();
if (!string.IsNullOrEmpty(labelName))
{
response.Labels.Add(labelName);
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse torrent list JSON response");
throw new UTorrentParsingException($"Failed to parse torrent list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing torrent list response");
throw new UTorrentParsingException($"Unexpected error parsing torrent list response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public FileListResponse ParseFileList(string json)
{
try
{
var rawResponse = JsonConvert.DeserializeObject<FileListResponse>(json);
if (rawResponse == null)
{
throw new UTorrentParsingException("Failed to deserialize file list response", json);
}
var response = new FileListResponse();
// Parse files from the nested array structure
if (rawResponse.FilesRaw is { Length: >= 2 })
{
response.Hash = rawResponse.FilesRaw[0].ToString() ?? string.Empty;
if (rawResponse.FilesRaw[1] is JArray jArray)
{
foreach (var jToken in jArray)
{
if (jToken is JArray fileArray)
{
var fileData = fileArray.ToObject<object[]>() ?? Array.Empty<object>();
if (fileData.Length >= 4)
{
response.Files.Add(new UTorrentFile
{
Name = fileData[0]?.ToString() ?? string.Empty,
Size = Convert.ToInt64(fileData[1]),
Downloaded = Convert.ToInt64(fileData[2]),
Priority = Convert.ToInt32(fileData[3]),
});
}
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse file list JSON response");
throw new UTorrentParsingException($"Failed to parse file list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing file list response");
throw new UTorrentParsingException($"Unexpected error parsing file list response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public PropertiesResponse ParseProperties(string json)
{
try
{
var rawResponse = JsonConvert.DeserializeObject<PropertiesResponse>(json);
if (rawResponse == null)
{
throw new UTorrentParsingException("Failed to deserialize properties response", json);
}
var response = new PropertiesResponse();
// Parse properties from the array structure
if (rawResponse.PropertiesRaw is { Length: > 0 })
{
response.Properties = JsonConvert.DeserializeObject<UTorrentProperties>(rawResponse.PropertiesRaw.FirstOrDefault()?.ToString());
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse properties JSON response");
throw new UTorrentParsingException($"Failed to parse properties response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing properties response");
throw new UTorrentParsingException($"Unexpected error parsing properties response: {ex.Message}", json, ex);
}
}
/// <inheritdoc/>
public LabelListResponse ParseLabelList(string json)
{
try
{
var response = JsonConvert.DeserializeObject<LabelListResponse>(json);
if (response == null)
{
throw new UTorrentParsingException("Failed to deserialize label list response", json);
}
// Parse labels
if (response.LabelsRaw != null)
{
foreach (var labelData in response.LabelsRaw)
{
if (labelData is { Length: > 0 })
{
var labelName = labelData[0]?.ToString();
if (!string.IsNullOrEmpty(labelName))
{
response.Labels.Add(labelName);
}
}
}
}
return response;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse label list JSON response");
throw new UTorrentParsingException($"Failed to parse label list response: {ex.Message}", json, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing label list response");
throw new UTorrentParsingException($"Unexpected error parsing label list response: {ex.Message}", json, ex);
}
}
}

View File

@@ -0,0 +1,128 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// µTorrent download service implementation
/// Provides business logic layer on top of UTorrentClient
/// </summary>
public partial class UTorrentService : DownloadService, IUTorrentService
{
private readonly UTorrentClient _client;
public UTorrentService(
ILogger<UTorrentService> logger,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
EventPublisher eventPublisher,
BlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
ILoggerFactory loggerFactory
) : base(
logger, cache,
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig
)
{
// Create the new layered client with dependency injection
var httpService = new UTorrentHttpService(_httpClient, downloadClientConfig, loggerFactory.CreateLogger<UTorrentHttpService>());
var authenticator = new UTorrentAuthenticator(
cache,
httpService,
downloadClientConfig,
loggerFactory.CreateLogger<UTorrentAuthenticator>()
);
var responseParser = new UTorrentResponseParser(loggerFactory.CreateLogger<UTorrentResponseParser>());
_client = new UTorrentClient(
downloadClientConfig,
authenticator,
httpService,
responseParser,
loggerFactory.CreateLogger<UTorrentClient>()
);
}
public override void Dispose()
{
}
/// <summary>
/// Authenticates with µTorrent Web UI
/// </summary>
public override async Task LoginAsync()
{
try
{
var loginSuccess = await _client.LoginAsync();
if (!loginSuccess)
{
throw new InvalidOperationException("Failed to authenticate with µTorrent Web UI");
}
_logger.LogDebug("Successfully logged in to µTorrent client {clientId}", _downloadClientConfig.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to login to µTorrent client {clientId}", _downloadClientConfig.Id);
throw;
}
}
/// <summary>
/// Performs health check for µTorrent service
/// </summary>
public override async Task<HealthCheckResult> HealthCheckAsync()
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Test authentication and basic connectivity
await _client.LoginAsync();
// Test API connectivity with a simple request
var connectionOk = await _client.TestConnectionAsync();
if (!connectionOk)
{
throw new InvalidOperationException("API connection test failed");
}
_logger.LogDebug("Health check: Successfully connected to µTorrent client {clientId}", _downloadClientConfig.Id);
stopwatch.Stop();
return new HealthCheckResult
{
IsHealthy = true,
ResponseTime = stopwatch.Elapsed
};
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Health check failed for µTorrent client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = false,
ResponseTime = stopwatch.Elapsed,
ErrorMessage = ex.Message
};
}
}
}

View File

@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
hash = hash.ToLowerInvariant();
UTorrentItem? download = await _client.GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
{
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(hash);
if (files?.Count is null or 0)
{
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}
List<int> fileIndexes = new(files.Count);
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < files.Count; i++)
{
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
var file = files[i];
if (file.Priority == 0) // Already skipped
{
totalUnwantedFiles++;
continue;
}
if (file.Priority != 0 && !_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
fileIndexes.Add(i);
_logger.LogInformation("unwanted file found | {file}", file.Name);
}
}
if (fileIndexes.Count is 0)
{
return result;
}
_logger.LogDebug("changing priorities | torrent {hash}", hash);
if (totalUnwantedFiles == files.Count)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, fileIndexes);
return result;
}
protected virtual async Task ChangeFilesPriority(string hash, List<int> fileIndexes)
{
await _client.SetFilesPriorityAsync(hash, fileIndexes, 0);
}
}

View File

@@ -0,0 +1,234 @@
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
public override async Task<List<object>?> GetSeedingDownloads()
{
var torrents = await _client.GetTorrentsAsync();
return torrents
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.IsSeeding())
.Cast<object>()
.ToList();
}
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
downloads
?.Cast<UTorrentItem>()
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
downloads
?.Cast<UTorrentItem>()
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
/// <inheritdoc/>
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!downloadCleanerConfig.DeletePrivate && properties.IsPrivate)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
TimeSpan? seedingTime = download.SeedingTime;
if (seedingTime == null)
{
_logger.LogDebug("skip | could not determine seeding time | {name}", download.Name);
continue;
}
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime.Value, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
download.Name
);
await _eventPublisher.PublishDownloadCleaned(download.Ratio, seedingTime.Value, category.Name, result.Reason);
}
}
public override async Task CreateCategoryAsync(string name)
{
var existingLabels = await _client.GetLabelsAsync();
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
{
return;
}
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
}
foreach (UTorrentItem download in downloads.Cast<UTorrentItem>())
{
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
var properties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(download.Hash);
bool hasHardlinks = false;
foreach (var file in files ?? [])
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
if (hardlinkCount < 0)
{
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
hasHardlinks = true;
break;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
continue;
}
//TODO change label on download object
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(download.Label, downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", download.Name);
download.Label = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash)
{
hash = hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash]);
}
protected async Task CreateLabel(string name)
{
await UTorrentClient.CreateLabel(name);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabelAsync(hash, newLabel);
}
}

View File

@@ -0,0 +1,174 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
public partial class UTorrentService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
List<UTorrentFile>? files = null;
DownloadCheckResult result = new();
UTorrentItem? download = await _client.GetTorrentAsync(hash);
if (download?.Hash is null)
{
_logger.LogDebug("Failed to find torrent {hash} in the download client", hash);
return result;
}
result.Found = true;
var properties = await _client.GetTorrentPropertiesAsync(hash);
result.IsPrivate = properties.IsPrivate;
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Failed to get files for torrent {hash} in the download client", hash);
}
bool shouldRemove = files?.Count > 0;
foreach (var file in files ?? [])
{
if (file.Priority > 0) // 0 = skip, >0 = wanted
{
shouldRemove = false;
break;
}
}
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;
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate);
return result;
}
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(UTorrentItem torrent, bool isPrivate)
{
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent, isPrivate);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(torrent, isPrivate);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(UTorrentItem download, bool isPrivate)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogTrace("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (!download.IsDownloading())
{
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.StatusMessage, 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);
}
if (queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogTrace("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogTrace("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
ByteSize minSpeed = queueCleanerConfig.Slow.MinSpeedByteSize;
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(queueCleanerConfig.Slow.MaxTime);
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.ETA);
return await CheckIfSlow(
download.Hash,
download.Name,
minSpeed,
currentSpeed,
maxTime,
currentTime
);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(UTorrentItem download, bool isPrivate)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
{
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (!download.IsDownloading())
{
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.StatusMessage, download.Name);
return (false, DeleteReason.None);
}
if (download.DateCompleted > 0)
{
_logger.LogTrace("skip stalled check | download is completed | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed > 0 || download.ETA > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
return (false, DeleteReason.None);
}
ResetStalledStrikesOnProgress(download.Hash, download.Downloaded);
return (await _striker.StrikeAndCheckLimit(download.Hash, download.Name, queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
}
}

View File

@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
/// <summary>
/// µTorrent status bitfield constants
/// Based on the µTorrent Web UI API documentation
/// </summary>
public static class UTorrentStatus
{
public const int Started = 1; // 1 << 0
public const int Checking = 2; // 1 << 1
public const int StartAfterCheck = 4; // 1 << 2
public const int Checked = 8; // 1 << 3
public const int Error = 16; // 1 << 4
public const int Paused = 32; // 1 << 5
public const int Queued = 64; // 1 << 6
public const int Loaded = 128; // 1 << 7
}

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

@@ -10,9 +10,17 @@ public static class CacheKeys
public static string BlocklistPatterns(InstanceType instanceType) => $"{instanceType.ToString()}_patterns";
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
public static string KnownMalwarePatterns() => "KNOWN_MALWARE_PATTERNS";
public static string StrikeItem(string hash, StrikeType strikeType) => $"item_{hash}_{strikeType.ToString()}";
public static string IgnoredDownloads(string name) => $"{name}_ignored";
public static string DownloadMarkedForRemoval(string hash, Uri url) => $"remove_{hash.ToLowerInvariant()}_{url}";
public static class UTorrent
{
public static string GetAuthTokenKey(string clientId) => $"utorrent:auth:token:{clientId}";
public static string GetGuidCookieKey(string clientId) => $"utorrent:auth:cookie:{clientId}";
}
}

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

@@ -6,6 +6,6 @@ namespace Cleanuparr.Infrastructure.Models;
public enum JobType
{
QueueCleaner,
ContentBlocker,
MalwareBlocker,
DownloadCleaner
}

View File

@@ -52,7 +52,7 @@ public static class CronValidationHelper
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (jobType is not JobType.ContentBlocker && triggerValue < Constants.TriggerMinLimit)
if (jobType is not JobType.MalwareBlocker && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}

View File

@@ -0,0 +1,645 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20250801143446_AddKnownMalwareOption")]
partial class AddKnownMalwareOption
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Tags")
.HasColumnType("TEXT")
.HasColumnName("tags");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddKnownMalwareOption : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "delete_known_malware",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "delete_known_malware",
table: "content_blocker_configs");
}
}
}

View File

@@ -91,6 +91,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");

View File

@@ -19,6 +19,8 @@ public sealed record ContentBlockerConfig : IJobConfig
public bool IgnorePrivate { get; set; }
public bool DeletePrivate { get; set; }
public bool DeleteKnownMalware { get; set; }
public BlocklistSettings Sonarr { get; set; } = new();

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Caching.Memory;
namespace Cleanuparr.Shared.Helpers;
public static class Constants
{
@@ -7,4 +9,9 @@ public static class Constants
public static readonly TimeSpan CacheLimitBuffer = TimeSpan.FromHours(2);
public const string HttpClientWithRetryName = "retry";
public static readonly MemoryCacheEntryOptions DefaultCacheEntryOptions = new()
{
SlidingExpiration = TimeSpan.FromMinutes(10)
};
}

View File

@@ -6,11 +6,28 @@ export const routes: Routes = [
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
{ path: 'events', loadComponent: () => import('./events/events-viewer/events-viewer.component').then(m => m.EventsViewerComponent) },
{
path: 'settings',
loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent),
path: 'general-settings',
loadComponent: () => import('./settings/general-settings/general-settings.component').then(m => m.GeneralSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'queue-cleaner',
loadComponent: () => import('./settings/queue-cleaner/queue-cleaner-settings.component').then(m => m.QueueCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'content-blocker',
loadComponent: () => import('./settings/content-blocker/content-blocker-settings.component').then(m => m.ContentBlockerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'download-cleaner',
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },

View File

@@ -71,6 +71,7 @@ export class DocumentationService {
'jobSchedule.type': 'run-schedule',
'ignorePrivate': 'ignore-private',
'deletePrivate': 'delete-private',
'deleteKnownMalware': 'delete-known-malware',
'sonarr.enabled': 'enable-sonarr-blocklist',
'sonarr.blocklistPath': 'sonarr-blocklist-path',
'sonarr.blocklistType': 'sonarr-blocklist-type',
@@ -84,7 +85,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

@@ -10,115 +10,105 @@
</div>
<!-- Sidebar Navigation -->
<nav class="nav-menu">
<!-- Project Sponsors Link -->
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
<!-- Show loading skeleton while determining navigation state -->
<nav class="nav-menu" *ngIf="!isNavigationReady">
<div class="nav-skeleton">
<div class="skeleton-item"
*ngFor="let item of getSkeletonItems()"
[class.sponsor-skeleton]="item.isSponsor">
</div>
</div>
</nav>
<!-- Show actual navigation when ready -->
<nav class="nav-menu"
*ngIf="isNavigationReady"
[@staggerItems]>
<!-- Project Sponsors Link (always visible) -->
<a href="https://cleanuparr.github.io/Cleanuparr/support"
class="nav-item sponsor-link"
target="_blank"
rel="noopener noreferrer">
<div class="nav-icon-wrapper heart-icon">
<i class="pi pi-heart"></i>
</div>
<span>Become A Sponsor</span>
</a>
<a [routerLink]="['/dashboard']" class="nav-item" [class.active]="router.url.includes('/dashboard')" (click)="onNavItemClick()">
<!-- Go Back button (shown when not at root level) -->
<div class="nav-item go-back-button"
*ngIf="canGoBack"
(click)="goBack()">
<div class="nav-icon-wrapper">
<i class="pi pi-home"></i>
<i class="pi pi-arrow-left"></i>
</div>
<span>Dashboard</span>
</a>
<!-- Settings Group -->
<div class="nav-group">
<div class="nav-group-title">Settings</div>
<a [routerLink]="['/sonarr']" class="nav-item" [class.active]="router.url.includes('/sonarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-play-circle"></i>
</div>
<span>Sonarr</span>
</a>
<a [routerLink]="['/radarr']" class="nav-item" [class.active]="router.url.includes('/radarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-play-circle"></i>
</div>
<span>Radarr</span>
</a>
<a [routerLink]="['/lidarr']" class="nav-item" [class.active]="router.url.includes('/lidarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-bolt"></i>
</div>
<span>Lidarr</span>
</a>
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-book"></i>
</div>
<span>Readarr</span>
</a>
<a [routerLink]="['/whisparr']" class="nav-item" [class.active]="router.url.includes('/whisparr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-lock"></i>
</div>
<span>Whisparr</span>
</a>
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-download"></i>
</div>
<span>Download Clients</span>
</a>
<a [routerLink]="['/settings']" class="nav-item" [class.active]="router.url.includes('/settings')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-trash"></i>
</div>
<span>Cleanup</span>
</a>
<a [routerLink]="['/notifications']" class="nav-item" [class.active]="router.url.includes('/notifications')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-bell"></i>
</div>
<span>Notifications</span>
</a>
<span>Go Back</span>
</div>
<!-- Activity Group -->
<div class="nav-group">
<div class="nav-group-title">Activity</div>
<ng-container *ngFor="let item of menuItems">
<ng-container *ngIf="!['Dashboard', 'Settings'].includes(item.label)">
<a [routerLink]="item.route" class="nav-item" [class.active]="router.url.includes(item.route)" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</a>
</ng-container>
<!-- Breadcrumb (optional, for better UX) -->
<div class="breadcrumb"
*ngIf="navigationBreadcrumb.length > 0">
<span *ngFor="let crumb of navigationBreadcrumb; let last = last; trackBy: trackByBreadcrumb">
{{ crumb.label }}
<i class="pi pi-chevron-right" *ngIf="!last"></i>
</span>
</div>
<!-- Navigation items container with container-level animation -->
<div class="navigation-items-container"
[@navigationContainer]="navigationStateKey">
<!-- Current level navigation items -->
<ng-container *ngFor="let item of currentNavigation; trackBy: trackByItemId">
<!-- Section headers for top-level sections -->
<div
class="nav-section-header"
*ngIf="item.isHeader">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</div>
<!-- Items with children (drill-down) - exclude top-level items -->
<div
class="nav-item nav-parent"
*ngIf="item.children && item.children.length > 0 && !item.topLevel"
(click)="navigateToLevel(item)">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
<div class="nav-chevron">
<i class="pi pi-chevron-right"></i>
</div>
</div>
<!-- Direct navigation items -->
<a
[routerLink]="item.route"
class="nav-item"
*ngIf="!item.children && item.route && !item.isHeader"
[class.active]="router.url.includes(item.route)"
(click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
<span class="nav-badge" *ngIf="item.badge">{{ item.badge }}</span>
</a>
<!-- External links -->
<a
[href]="item.href"
class="nav-item"
*ngIf="!item.children && item.isExternal && !item.isHeader"
target="_blank"
rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</a>
</ng-container>
</div>
<!-- Resources Group -->
<div class="nav-group">
<div class="nav-group-title">Resources</div>
<a href="https://github.com/Cleanuparr/Cleanuparr/issues" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-github"></i>
</div>
<span>Issues and requests</span>
</a>
<a href="https://discord.gg/SCtMCgtsc4" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-discord"></i>
</div>
<span>Discord</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-title">Recommended apps</div>
<a href="https://github.com/plexguide/Huntarr.io" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-github"></i>
</div>
<span>Huntarr</span>
</a>
</div>
</nav>

View File

@@ -1,3 +1,11 @@
// Main container stability
:host {
display: block;
height: 100%;
overflow: hidden; // Prevent scrolling
position: relative;
}
// Logo container
.logo-container {
display: flex;
@@ -50,7 +58,69 @@
display: flex;
flex-direction: column;
flex: 1;
gap: 1rem;
gap: 0; // Remove gap to prevent layout shifts
transition: opacity 0.2s ease;
// Prevent horizontal scrolling
overflow-x: hidden;
overflow-y: auto;
// Fixed minimum height to prevent jumping
min-height: 400px;
// Navigation items container for smooth animations
.navigation-items-container {
display: flex;
flex-direction: column;
gap: 8px; // Consistent spacing between navigation items
position: relative; // Ensure proper stacking context for animations
width: 100%; // Take full width of parent
}
// Loading skeleton
.nav-skeleton {
padding: 0;
.skeleton-item {
height: 60px; // Match actual nav-item height
padding: 10px 20px; // Match nav-item padding
margin-bottom: 8px; // Match nav-item spacing
display: flex;
align-items: center;
border-radius: 6px;
background: linear-gradient(90deg, var(--surface-200) 25%, var(--surface-300) 50%, var(--surface-200) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
&:last-child {
margin-bottom: 0;
}
&.sponsor-skeleton {
margin-bottom: 15px;
}
// Add fake icon and text areas to match real content
&::before {
content: '';
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--surface-300);
margin-right: 15px;
flex-shrink: 0;
}
&::after {
content: '';
height: 20px;
background: var(--surface-300);
border-radius: 4px;
flex: 1;
max-width: 120px;
}
}
}
// Sponsor link
.sponsor-link {
@@ -67,20 +137,78 @@
}
}
}
// Nav groups
.nav-group {
// Go back button styling
.go-back-button {
background-color: var(--surface-200);
border: 1px solid var(--surface-300);
margin-bottom: 15px;
cursor: pointer;
.nav-group-title {
font-size: 12px;
font-weight: 700;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 2px;
padding: 0 20px 8px;
margin: 5px 0;
border-bottom: 1px solid var(--surface-border);
&:hover {
transform: translateX(2px);
background-color: var(--surface-300);
.nav-icon-wrapper i {
transform: translateX(-2px);
}
}
}
// Breadcrumb styling
.breadcrumb {
padding: 8px 20px;
font-size: 12px;
color: var(--text-color-secondary);
border-bottom: 1px solid var(--surface-border);
margin-bottom: 10px;
overflow: hidden;
transition: all 0.25s ease;
span {
transition: all 0.2s ease;
}
i {
margin: 0 8px;
font-size: 10px;
transition: all 0.2s ease;
}
}
// Section headers for top-level sections
.nav-section-header {
display: flex;
align-items: center;
padding: 8px 20px 4px;
color: var(--text-color-secondary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin: 15px 0 8px 0;
border-bottom: 1px solid var(--surface-border);
.nav-icon-wrapper {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 12px;
border-radius: 4px;
background: var(--surface-200);
flex-shrink: 0;
i {
font-size: 12px;
color: var(--text-color-secondary);
}
}
span {
font-size: 11px;
font-weight: 600;
}
}
@@ -94,7 +222,12 @@
border-radius: 0 6px 6px 0;
position: relative;
overflow: hidden;
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
margin-bottom: 8px; // Consistent spacing instead of gap
&:last-child {
margin-bottom: 0;
}
.nav-icon-wrapper {
width: 40px;
@@ -106,17 +239,33 @@
border-radius: 8px;
background: var(--surface-card);
border: 1px solid var(--surface-border);
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
flex-shrink: 0; // Prevent icon from shrinking
i {
font-size: 20px;
color: var(--text-color-secondary);
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
}
span {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
flex: 1; // Take available space
}
.nav-badge {
margin-left: auto;
background-color: var(--primary-color);
color: var(--primary-color-text);
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
&::before {
@@ -131,7 +280,9 @@
}
&:hover {
transform: translateX(4px);
background-color: var(--surface-hover);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.nav-icon-wrapper {
background-color: rgba(var(--primary-500-rgb), 0.1);
@@ -161,6 +312,38 @@
}
}
}
// Parent navigation items (with children)
.nav-parent {
cursor: pointer;
position: relative;
.nav-chevron {
margin-left: auto;
opacity: 0.6;
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
flex-shrink: 0;
i {
font-size: 16px;
transition: transform 0.2s ease;
}
}
&:hover .nav-chevron i {
transform: translateX(3px) scale(1.1);
}
}
}
// Loading skeleton animation
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
// Animation keyframes

View File

@@ -1,7 +1,10 @@
import { Component, Input, inject, Output, EventEmitter } from '@angular/core';
import { Component, Input, inject, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { ButtonModule } from 'primeng/button';
import { filter } from 'rxjs/operators';
import { Subscription } from 'rxjs';
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';
interface MenuItem {
label: string;
@@ -10,6 +13,24 @@ interface MenuItem {
badge?: string;
}
interface NavigationItem {
id: string;
label: string;
icon: string;
route?: string; // For direct navigation items
children?: NavigationItem[]; // For parent items with sub-menus
isExternal?: boolean; // For external links
href?: string; // For external URLs
badge?: string; // For notification badges
topLevel?: boolean; // If true, shows children directly on top level instead of drill-down
isHeader?: boolean; // If true, renders as a section header (non-clickable)
}
interface RouteMapping {
route: string;
navigationPath: string[]; // Array of navigation item IDs leading to this route
}
@Component({
selector: 'app-sidebar-content',
standalone: true,
@@ -19,9 +40,37 @@ interface MenuItem {
ButtonModule
],
templateUrl: './sidebar-content.component.html',
styleUrl: './sidebar-content.component.scss'
styleUrl: './sidebar-content.component.scss',
animations: [
trigger('staggerItems', [
transition(':enter', [
query(':enter', [
style({ transform: 'translateX(30px)', opacity: 0 }),
stagger('50ms', [
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)', style({ transform: 'translateX(0)', opacity: 1 }))
])
], { optional: true })
])
]),
// Container-level navigation animation (replaces individual item animations)
trigger('navigationContainer', [
transition('* => *', [
style({ transform: 'translateX(100%)', opacity: 0 }),
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)',
style({ transform: 'translateX(0)', opacity: 1 })
)
])
]),
// Simple fade in animation for initial load
trigger('fadeIn', [
transition(':enter', [
style({ opacity: 0 }),
animate('200ms ease-out', style({ opacity: 1 }))
])
])
]
})
export class SidebarContentComponent {
export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
@Input() menuItems: MenuItem[] = [];
@Input() isMobile = false;
@Output() navItemClicked = new EventEmitter<void>();
@@ -29,6 +78,404 @@ export class SidebarContentComponent {
// Inject router for active route styling
public router = inject(Router);
// New properties for drill-down navigation
navigationData: NavigationItem[] = [];
currentNavigation: NavigationItem[] = [];
navigationBreadcrumb: NavigationItem[] = [];
canGoBack = false;
// Pre-rendering optimization properties
isNavigationReady = false;
private hasInitialized = false;
// Animation trigger property - changes to force re-render and trigger animations
navigationStateKey = 0;
// Route synchronization properties
private routerSubscription?: Subscription;
private routeMappings: RouteMapping[] = [
// Dashboard
{ route: '/dashboard', navigationPath: ['dashboard'] },
// Media Management routes
{ route: '/sonarr', navigationPath: ['media-apps', 'sonarr'] },
{ route: '/radarr', navigationPath: ['media-apps', 'radarr'] },
{ route: '/lidarr', navigationPath: ['media-apps', 'lidarr'] },
{ route: '/readarr', navigationPath: ['media-apps', 'readarr'] },
{ route: '/whisparr', navigationPath: ['media-apps', 'whisparr'] },
{ route: '/download-clients', navigationPath: ['media-apps', 'download-clients'] },
// Settings routes
{ route: '/general-settings', navigationPath: ['settings', 'general'] },
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
{ route: '/content-blocker', navigationPath: ['settings', 'content-blocker'] },
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
// Other routes will be handled dynamically
];
ngOnInit(): void {
// Start with loading state
this.isNavigationReady = false;
// Initialize navigation after showing skeleton
setTimeout(() => {
this.initializeNavigation();
}, 100);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['menuItems']) {
this.updateActivityItems();
}
}
ngOnDestroy(): void {
this.routerSubscription?.unsubscribe();
}
/**
* Initialize navigation and determine correct level based on route
*/
private initializeNavigation(): void {
if (this.hasInitialized) return;
// 1. Initialize navigation data
this.setupNavigationData();
// 2. Update activity items if available
if (this.menuItems && this.menuItems.length > 0) {
this.updateActivityItems();
}
// 3. Determine correct navigation level based on current route
this.syncSidebarWithCurrentRoute();
// 4. Mark as ready and subscribe to route changes
this.isNavigationReady = true;
this.hasInitialized = true;
this.subscribeToRouteChanges();
}
/**
* Setup basic navigation data structure
*/
private setupNavigationData(): void {
this.navigationData = this.getNavigationData();
this.currentNavigation = this.buildTopLevelNavigation();
}
/**
* Build top-level navigation including expanded sections marked with topLevel: true
*/
private buildTopLevelNavigation(): NavigationItem[] {
const topLevelItems: NavigationItem[] = [];
for (const item of this.navigationData) {
if (item.topLevel && item.children) {
// Add section header
topLevelItems.push({
id: `${item.id}-header`,
label: item.label,
icon: item.icon,
isHeader: true
});
// Add all children directly to top level
topLevelItems.push(...item.children);
} else {
// Add item normally (drill-down behavior)
topLevelItems.push(item);
}
}
return topLevelItems;
}
/**
* Get the navigation data structure
*/
private getNavigationData(): NavigationItem[] {
return [
{
id: 'dashboard',
label: 'Dashboard',
icon: 'pi pi-home',
route: '/dashboard'
},
{
id: 'media-apps',
label: 'Media Apps',
icon: 'pi pi-play-circle',
children: [
{ id: 'sonarr', label: 'Sonarr', icon: 'pi pi-play-circle', route: '/sonarr' },
{ id: 'radarr', label: 'Radarr', icon: 'pi pi-play-circle', route: '/radarr' },
{ id: 'lidarr', label: 'Lidarr', icon: 'pi pi-bolt', route: '/lidarr' },
{ id: 'readarr', label: 'Readarr', icon: 'pi pi-book', route: '/readarr' },
{ id: 'whisparr', label: 'Whisparr', icon: 'pi pi-lock', route: '/whisparr' },
{ id: 'download-clients', label: 'Download Clients', icon: 'pi pi-download', route: '/download-clients' }
]
},
{
id: 'settings',
label: 'Settings',
icon: 'pi pi-cog',
children: [
{ id: 'general', label: 'General', icon: 'pi pi-cog', route: '/general-settings' },
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
{ id: 'content-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/content-blocker' },
{ id: 'download-cleaner', label: 'Download Cleaner', icon: 'pi pi-trash', route: '/download-cleaner' },
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
]
},
{
id: 'activity',
label: 'Activity',
icon: 'pi pi-chart-line',
children: [] // Will be populated dynamically from menuItems
},
{
id: 'help-support',
label: 'Help & Support',
icon: 'pi pi-question-circle',
children: [
{
id: 'issues',
label: 'Issues and Requests',
icon: 'pi pi-github',
isExternal: true,
href: 'https://github.com/Cleanuparr/Cleanuparr/issues'
},
{
id: 'discord',
label: 'Discord',
icon: 'pi pi-discord',
isExternal: true,
href: 'https://discord.gg/SCtMCgtsc4'
},
]
},
{
id: 'suggested-apps',
label: 'Suggested Apps',
topLevel: true,
icon: 'pi pi-star',
children: [
{
id: 'huntarr',
label: 'Huntarr',
icon: 'pi pi-github',
isExternal: true,
href: 'https://github.com/plexguide/Huntarr.io'
}
]
}
];
}
/**
* Navigate to route mapping synchronously without delays
*/
private navigateToRouteMappingSync(mapping: RouteMapping): void {
// No delays, no async operations - just set the state
this.navigationBreadcrumb = [];
this.currentNavigation = this.buildTopLevelNavigation();
for (let i = 0; i < mapping.navigationPath.length - 1; i++) {
const itemId = mapping.navigationPath[i];
// Find in original navigation data, not the flattened version
const item = this.navigationData.find(nav => nav.id === itemId);
if (item && item.children && !item.topLevel) {
// Only drill down if it's not a top-level section
this.navigationBreadcrumb.push(item);
this.currentNavigation = [...item.children];
}
}
this.updateNavigationState();
}
/**
* Get skeleton items based on predicted navigation state
*/
getSkeletonItems(): Array<{isSponsor: boolean}> {
const currentRoute = this.router.url;
const mapping = this.findRouteMapping(currentRoute);
if (mapping && mapping.navigationPath.length > 1) {
// We'll show sub-navigation, predict item count
return [
{ isSponsor: true },
{ isSponsor: false }, // Go back
...Array(6).fill({ isSponsor: false }) // Estimated items
];
}
// Default main navigation count
return [
{ isSponsor: true },
...Array(5).fill({ isSponsor: false })
];
}
/**
* TrackBy function for better performance
*/
trackByItemId(index: number, item: NavigationItem): string {
return item.id;
}
/**
* TrackBy function that includes navigation state for animation triggers
*/
trackByItemIdWithState(index: number, item: NavigationItem): string {
return `${item.id}-${this.navigationStateKey}`;
}
/**
* TrackBy function for breadcrumb items
*/
trackByBreadcrumb(index: number, item: NavigationItem): string {
return `${item.id}-${index}`;
}
/**
* Update activity items from menuItems input
*/
private updateActivityItems(): void {
const activityItem = this.navigationData.find(item => item.id === 'activity');
if (activityItem && this.menuItems) {
activityItem.children = this.menuItems
.filter(item => !['Dashboard', 'Settings'].includes(item.label))
.map(item => ({
id: item.label.toLowerCase().replace(/\s+/g, '-'),
label: item.label,
icon: item.icon,
route: item.route,
badge: item.badge
}));
// Update route mappings for activity items
this.updateActivityRouteMappings();
// Update current navigation if we're showing the root level
if (this.navigationBreadcrumb.length === 0) {
this.currentNavigation = this.buildTopLevelNavigation();
}
// Re-sync with current route to handle activity routes
this.syncSidebarWithCurrentRoute();
}
}
/**
* Update route mappings for activity items
*/
private updateActivityRouteMappings(): void {
// Remove old activity mappings
this.routeMappings = this.routeMappings.filter(mapping =>
!mapping.navigationPath[0] || !mapping.navigationPath[0].startsWith('activity')
);
// Add new activity mappings
const activityItem = this.navigationData.find(item => item.id === 'activity');
if (activityItem?.children) {
activityItem.children.forEach(child => {
if (child.route) {
this.routeMappings.push({
route: child.route,
navigationPath: ['activity', child.id]
});
}
});
}
}
/**
* Sync sidebar state with current route
*/
private syncSidebarWithCurrentRoute(): void {
const currentRoute = this.router.url;
const mapping = this.findRouteMapping(currentRoute);
if (mapping) {
this.navigateToRouteMapping(mapping);
}
}
/**
* Find route mapping for current route
*/
private findRouteMapping(route: string): RouteMapping | null {
// Find exact match first, or routes that start with the mapping route
const mapping = this.routeMappings.find(m =>
route === m.route || route.startsWith(m.route + '/')
);
return mapping || null;
}
/**
* Navigate sidebar to match route mapping (used by route sync)
*/
private navigateToRouteMapping(mapping: RouteMapping): void {
// Use the synchronous version
this.navigateToRouteMappingSync(mapping);
}
/**
* Subscribe to route changes for real-time synchronization
*/
private subscribeToRouteChanges(): void {
this.routerSubscription = this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
this.syncSidebarWithCurrentRoute();
});
}
/**
* Navigate to a sub-level with animation trigger
*/
navigateToLevel(item: NavigationItem): void {
if (item.children && item.children.length > 0) {
this.navigationBreadcrumb.push(item);
this.currentNavigation = item.children ? [...item.children] : [];
this.navigationStateKey++; // Force animation trigger
this.updateNavigationState();
}
}
/**
* Go back to the previous level with animation trigger
*/
goBack(): void {
if (this.navigationBreadcrumb.length > 0) {
this.navigationBreadcrumb.pop();
if (this.navigationBreadcrumb.length === 0) {
// Back to root level - use top-level navigation
this.currentNavigation = this.buildTopLevelNavigation();
} else {
// Back to parent level
const parent = this.navigationBreadcrumb[this.navigationBreadcrumb.length - 1];
this.currentNavigation = parent.children ? [...parent.children] : [];
}
this.navigationStateKey++; // Force animation trigger
this.updateNavigationState();
}
}
/**
* Update navigation state
*/
private updateNavigationState(): void {
this.canGoBack = this.navigationBreadcrumb.length > 0;
}
/**
* Handle navigation item click
*/

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Malware Blocker</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Content Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Malware Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,21 +28,21 @@
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Content Blocker
Enable Malware Blocker
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the content blocker will run according to the schedule</small>
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
</div>
</div>
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -92,7 +95,7 @@
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -109,27 +112,40 @@
<!-- Content Blocker Specific Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
<small class="form-helper-text">When enabled, private torrents will not be processed</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deleteKnownMalware')"
title="Click for documentation"></i>
Delete Known Malware
</label>
<div class="field-input">
<p-checkbox formControlName="deleteKnownMalware" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, downloads matching known malware patterns will be deleted</small>
</div>
</div>
@@ -151,7 +167,7 @@
<div formGroupName="sonarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.enabled')"
title="Click for documentation"></i>
Enable Sonarr Blocklist
@@ -164,7 +180,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -180,7 +196,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -221,7 +237,7 @@
<div formGroupName="radarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.enabled')"
title="Click for documentation"></i>
Enable Radarr Blocklist
@@ -234,7 +250,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -250,7 +266,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -291,7 +307,7 @@
<div formGroupName="lidarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.enabled')"
title="Click for documentation"></i>
Enable Lidarr Blocklist
@@ -304,7 +320,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -320,7 +336,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -361,7 +377,7 @@
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
@@ -374,7 +390,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -390,7 +406,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -431,7 +447,7 @@
<div formGroupName="whisparr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.enabled')"
title="Click for documentation"></i>
Enable Whisparr Blocklist
@@ -444,7 +460,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -460,7 +476,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -509,4 +525,5 @@
</div>
</form>
</div>
</p-card>
</p-card>
</div>

View File

@@ -1,3 +1,5 @@
/* Content Blocker Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -132,6 +132,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
deleteKnownMalware: [{ value: false, disabled: true }],
// Blocklist settings for each Arr
sonarr: this.formBuilder.group({
@@ -165,26 +166,36 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
effect(() => {
const config = this.contentBlockerConfig();
if (config) {
// Reset form with the config values
// Handle the case where ignorePrivate is true but deletePrivate is also true
// This shouldn't happen, but if it does, correct it
const correctedConfig = { ...config };
// For Content Blocker
if (correctedConfig.ignorePrivate && correctedConfig.deletePrivate) {
correctedConfig.deletePrivate = false;
}
// Reset form with the corrected config values
this.contentBlockerForm.patchValue({
enabled: config.enabled,
useAdvancedScheduling: config.useAdvancedScheduling || false,
cronExpression: config.cronExpression,
jobSchedule: config.jobSchedule || {
enabled: correctedConfig.enabled,
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
cronExpression: correctedConfig.cronExpression,
jobSchedule: correctedConfig.jobSchedule || {
every: 5,
type: ScheduleUnit.Seconds
},
ignorePrivate: config.ignorePrivate,
deletePrivate: config.deletePrivate,
sonarr: config.sonarr,
radarr: config.radarr,
lidarr: config.lidarr,
readarr: config.readarr,
whisparr: config.whisparr,
ignorePrivate: correctedConfig.ignorePrivate,
deletePrivate: correctedConfig.deletePrivate,
deleteKnownMalware: correctedConfig.deleteKnownMalware,
sonarr: correctedConfig.sonarr,
radarr: correctedConfig.radarr,
lidarr: correctedConfig.lidarr,
readarr: correctedConfig.readarr,
whisparr: correctedConfig.whisparr,
});
// Update all form control states
this.updateFormControlDisabledStates(config);
this.updateFormControlDisabledStates(correctedConfig);
// Store original values for dirty checking
this.storeOriginalValues();
@@ -245,6 +256,27 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateMainControlsState(enabled);
});
}
// Add listener for ignorePrivate changes
const ignorePrivateControl = this.contentBlockerForm.get('ignorePrivate');
if (ignorePrivateControl) {
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((ignorePrivate: boolean) => {
const deletePrivateControl = this.contentBlockerForm.get('deletePrivate');
if (ignorePrivate && deletePrivateControl) {
// If ignoring private, uncheck and disable delete private
deletePrivateControl.setValue(false);
deletePrivateControl.disable({ onlySelf: true });
} else if (!ignorePrivate && deletePrivateControl) {
// If not ignoring private, enable delete private (if main feature is enabled)
const mainEnabled = this.contentBlockerForm.get('enabled')?.value || false;
if (mainEnabled) {
deletePrivateControl.enable({ onlySelf: true });
}
}
});
}
// Listen for changes to the 'useAdvancedScheduling' control
const advancedControl = this.contentBlockerForm.get('useAdvancedScheduling');
@@ -415,7 +447,17 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
// Enable content blocker specific controls
this.contentBlockerForm.get("ignorePrivate")?.enable({ onlySelf: true });
this.contentBlockerForm.get("deletePrivate")?.enable({ onlySelf: true });
this.contentBlockerForm.get("deleteKnownMalware")?.enable({ onlySelf: true });
// Only enable deletePrivate if ignorePrivate is false
const ignorePrivate = this.contentBlockerForm.get("ignorePrivate")?.value || false;
const deletePrivateControl = this.contentBlockerForm.get("deletePrivate");
if (!ignorePrivate && deletePrivateControl) {
deletePrivateControl.enable({ onlySelf: true });
} else if (deletePrivateControl) {
deletePrivateControl.disable({ onlySelf: true });
}
// Enable blocklist settings for each Arr
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
@@ -449,6 +491,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
// Disable content blocker specific controls
this.contentBlockerForm.get("ignorePrivate")?.disable({ onlySelf: true });
this.contentBlockerForm.get("deletePrivate")?.disable({ onlySelf: true });
this.contentBlockerForm.get("deleteKnownMalware")?.disable({ onlySelf: true });
// Disable all blocklist settings for each Arr
this.contentBlockerForm.get("sonarr.enabled")?.disable({ onlySelf: true });
@@ -494,6 +537,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
jobSchedule: formValue.jobSchedule,
ignorePrivate: formValue.ignorePrivate || false,
deletePrivate: formValue.deletePrivate || false,
deleteKnownMalware: formValue.deleteKnownMalware || false,
sonarr: formValue.sonarr || {
enabled: false,
blocklistPath: "",
@@ -572,6 +616,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
},
ignorePrivate: false,
deletePrivate: false,
deleteKnownMalware: false,
sonarr: {
enabled: false,
blocklistPath: "",

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Download Cleaner</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic download cleanup</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic download cleanup</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,7 +28,7 @@
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Download Cleaner
@@ -39,7 +42,7 @@
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -92,7 +95,7 @@
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="downloadCleanerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -124,7 +127,7 @@
<!-- Delete Private Option -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deletePrivate')"
title="Click for documentation"></i>
Delete Private Torrents
@@ -155,7 +158,7 @@
<div class="category-title">
<i class="pi pi-tag category-icon"></i>
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('name')"
title="Click for documentation"></i>
</div>
@@ -167,7 +170,7 @@
<div class="category-content">
<div class="category-field">
<label>
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('maxRatio')"
title="Click for documentation"></i>
Max Ratio
@@ -185,7 +188,7 @@
<div class="category-field">
<label>
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('minSeedTime')"
title="Click for documentation"></i>
Min Seed Time (hours)
@@ -202,7 +205,7 @@
<div class="category-field">
<label>
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('maxSeedTime')"
title="Click for documentation"></i>
Max Seed Time (hours)
@@ -249,7 +252,7 @@
<p-accordion-content>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedEnabled')"
title="Click for documentation"></i>
Enable Unlinked Download Handling
@@ -263,7 +266,7 @@
<!-- Unlinked Target Category -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedTargetCategory')"
title="Click for documentation"></i>
Target Category
@@ -274,13 +277,14 @@
</div>
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="p-error">Target category is required</small>
<small class="form-helper-text">Category to move unlinked downloads to</small>
<small class="form-helper-text">You have to create a seeding rule for this category if you want to remove the downloads</small>
</div>
</div>
<!-- Use Tag Option -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedUseTag')"
title="Click for documentation"></i>
Use Tag
@@ -294,7 +298,7 @@
<!-- Ignored Root Directory -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedIgnoredRootDir')"
title="Click for documentation"></i>
Ignored Root Directory
@@ -310,7 +314,7 @@
<!-- Unlinked Categories -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedCategories')"
title="Click for documentation"></i>
Unlinked Categories
@@ -370,8 +374,9 @@
</p-card>
<!-- Confirmation Dialog -->
<p-confirmDialog
[style]="{ width: '450px' }"
[baseZIndex]="10000"
rejectButtonStyleClass="p-button-text">
</p-confirmDialog>
<p-confirmDialog
[style]="{ width: '450px' }"
[baseZIndex]="10000"
rejectButtonStyleClass="p-button-text">
</p-confirmDialog>
</div>

View File

@@ -1,6 +1,8 @@
/* Download Cleaner Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';
.section-header {
margin-bottom: 1rem;

View File

@@ -793,7 +793,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
private showEnableConfirmationDialog(): void {
this.confirmationService.confirm({
header: 'Enable Download Cleaner',
message: 'To avoid affecting items that are awaiting to be imported, please ensure that your Sonarr, Radarr, and Lidarr instances have been properly configured prior to enabling the Download Cleaner.<br/><br/>Are you sure you want to proceed?',
message: 'To avoid affecting items that are awaiting to be imported, please ensure that your Sonarr, Radarr, and Lidarr instances have been configured prior to enabling the Download Cleaner.<br/><br/>Are you sure you want to proceed?',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-check',
rejectIcon: 'pi pi-times',

View File

@@ -115,7 +115,7 @@
<form [formGroup]="clientForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
pTooltip="Click for documentation"></i>
Enabled
@@ -128,7 +128,7 @@
<div class="field">
<label for="client-name">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('name')"
pTooltip="Click for documentation"></i>
Name *
@@ -146,28 +146,28 @@
<div class="field">
<label for="client-type">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('type')"
<i class="pi pi-question-circle field-info-icon"
(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>
<div class="field">
<label for="client-host">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('host')"
pTooltip="Click for documentation"></i>
Host *
@@ -187,7 +187,7 @@
<div class="field">
<label for="client-urlbase">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('urlBase')"
pTooltip="Click for documentation"></i>
URL Base
@@ -204,7 +204,7 @@
<div class="field" *ngIf="shouldShowUsernameField()">
<label for="client-username">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('username')"
pTooltip="Click for documentation"></i>
Username
@@ -221,7 +221,7 @@
<div class="field">
<label for="client-password">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('password')"
pTooltip="Click for documentation"></i>
Password

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,25 @@ 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:
case DownloadClientTypeName.uTorrent:
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 +354,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 +373,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,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>General Settings</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,7 +28,7 @@
<!-- Display Support Banner -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('displaySupportBanner')"
title="Click for documentation">
</i>
@@ -40,7 +43,7 @@
<!-- Dry Run -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="Click for documentation">
</i>
@@ -55,7 +58,7 @@
<!-- HTTP Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="Click for documentation">
</i>
@@ -79,7 +82,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="Click for documentation">
</i>
@@ -103,7 +106,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="Click for documentation">
</i>
@@ -124,7 +127,7 @@
<!-- Search Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="Click for documentation">
</i>
@@ -138,7 +141,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="Click for documentation">
</i>
@@ -163,7 +166,7 @@
<!-- Log Level -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('logLevel')"
title="Click for documentation">
</i>
@@ -184,7 +187,7 @@
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
@@ -241,3 +244,4 @@
[style]="{ width: '500px', maxWidth: '90vw' }"
[baseZIndex]="10000">
</p-confirmDialog>
</div>

View File

@@ -1,3 +1,5 @@
/* General Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -33,7 +33,7 @@
<!-- API Key -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('notifiarr.apiKey')"
title="Click for documentation">
</i>
@@ -41,30 +41,35 @@
</label>
<div class="field-input">
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
<small class="form-helper-text">Your Notifiarr API key for authentication</small>
<small class="form-helper-text">Passthrough integration must be enabled and a Passthrough key needs to be created in your profile</small>
</div>
</div>
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="Click for documentation">
</i>
Channel ID
</label>
<div class="field-input">
<input type="text" pInputText formControlName="channelId" inputId="notifiarrChannelId" placeholder="Enter channel ID" />
<small class="form-helper-text">The channel ID where notifications will be sent</small>
<input
type="text"
pInputText
formControlName="channelId"
placeholder="Enter Discord channel ID"
numericInput
/>
<small class="form-helper-text">The Discord channel ID where notifications will be sent</small>
</div>
</div>
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>
@@ -110,7 +115,7 @@
<!-- URL -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.fullUrl')"
title="Click for documentation">
</i>
@@ -125,7 +130,7 @@
<!-- Key -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.key')"
title="Click for documentation">
</i>
@@ -140,7 +145,7 @@
<!-- Tags -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.tags')"
title="Click for documentation">
</i>
@@ -155,7 +160,7 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>

View File

@@ -5,10 +5,12 @@ import { Subject, takeUntil } from "rxjs";
import { NotificationConfigStore } from "./notification-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { NotificationsConfig } from "../../shared/models/notifications-config.model";
import { NumericInputDirective } from "../../shared/directives";
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { InputNumberModule } from "primeng/inputnumber";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { ToastModule } from "primeng/toast";
@@ -24,10 +26,12 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
ReactiveFormsModule,
CardModule,
InputTextModule,
InputNumberModule,
CheckboxModule,
ButtonModule,
ToastModule,
LoadingErrorStateComponent,
NumericInputDirective,
],
providers: [NotificationConfigStore],
templateUrl: "./notification-settings.component.html",
@@ -221,7 +225,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
const formValues = this.notificationForm.value;
const config: NotificationsConfig = {
notifiarr: formValues.notifiarr,
notifiarr: {
...formValues.notifiarr,
channelId: formValues.notifiarr.channelId ? formValues.notifiarr.channelId.toString() : null,
},
apprise: formValues.apprise,
};

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Queue Cleaner</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,7 +28,7 @@
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Queue Cleaner
@@ -39,7 +42,7 @@
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -92,7 +95,7 @@
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="queueCleanerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -123,7 +126,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
@@ -148,33 +151,33 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
<small class="form-helper-text">When enabled, private torrents will not be checked for being failed imports</small>
</div>
</div>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
</div>
</div>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignoredPatterns')"
title="Click for documentation"></i>
Ignored Patterns
@@ -219,7 +222,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
@@ -244,7 +247,7 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
@@ -257,27 +260,27 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
<small class="form-helper-text">When enabled, private torrents will not be checked for being stalled</small>
</div>
</div>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
</div>
</div>
</p-accordion-content>
@@ -298,7 +301,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
title="Click for documentation"></i>
Max Strikes for Downloading Metadata
@@ -338,7 +341,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
@@ -363,7 +366,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
@@ -376,33 +379,33 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
<small class="form-helper-text">When enabled, private torrents will not be checked for being slow</small>
</div>
</div>
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
</div>
</div>
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.minSpeed')"
title="Click for documentation"></i>
Minimum Speed
@@ -413,6 +416,7 @@
[min]="0"
placeholder="Enter minimum speed"
helpText="Minimum speed threshold for slow downloads (e.g., 100KB/s)"
type="speed"
>
</app-byte-size-input>
</div>
@@ -420,7 +424,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.maxTime')"
title="Click for documentation"></i>
Maximum Time (hours)
@@ -443,7 +447,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.ignoreAboveSize')"
title="Click for documentation"></i>
Ignore Above Size
@@ -454,6 +458,7 @@
[min]="0"
placeholder="Enter size threshold"
helpText="Downloads will be ignored if size exceeds, e.g., 25 GB"
type="size"
>
</app-byte-size-input>
</div>
@@ -488,3 +493,4 @@
</form>
</div>
</p-card>
</div>

View File

@@ -1,3 +1,5 @@
/* Queue Cleaner Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -176,25 +176,40 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
effect(() => {
const config = this.queueCleanerConfig();
if (config) {
// Save original cron expression
const cronExpression = config.cronExpression;
// Handle the case where ignorePrivate is true but deletePrivate is also true
// This shouldn't happen, but if it does, correct it
const correctedConfig = { ...config };
// Reset form with the config values
// For Queue Cleaner (apply to all sections)
if (correctedConfig.failedImport?.ignorePrivate && correctedConfig.failedImport?.deletePrivate) {
correctedConfig.failedImport.deletePrivate = false;
}
if (correctedConfig.stalled?.ignorePrivate && correctedConfig.stalled?.deletePrivate) {
correctedConfig.stalled.deletePrivate = false;
}
if (correctedConfig.slow?.ignorePrivate && correctedConfig.slow?.deletePrivate) {
correctedConfig.slow.deletePrivate = false;
}
// Save original cron expression
const cronExpression = correctedConfig.cronExpression;
// Reset form with the corrected config values
this.queueCleanerForm.patchValue({
enabled: config.enabled,
useAdvancedScheduling: config.useAdvancedScheduling || false,
cronExpression: config.cronExpression,
jobSchedule: config.jobSchedule || {
enabled: correctedConfig.enabled,
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
cronExpression: correctedConfig.cronExpression,
jobSchedule: correctedConfig.jobSchedule || {
every: 5,
type: ScheduleUnit.Minutes
},
failedImport: config.failedImport,
stalled: config.stalled,
slow: config.slow,
failedImport: correctedConfig.failedImport,
stalled: correctedConfig.stalled,
slow: correctedConfig.slow,
});
// Then update all other dependent form control states
this.updateFormControlDisabledStates(config);
this.updateFormControlDisabledStates(correctedConfig);
// Store original values for dirty checking
this.storeOriginalValues();
@@ -255,6 +270,30 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
this.updateMainControlsState(enabled);
});
}
// Add listeners for ignorePrivate changes in each section
['failedImport', 'stalled', 'slow'].forEach(section => {
const ignorePrivateControl = this.queueCleanerForm.get(`${section}.ignorePrivate`);
if (ignorePrivateControl) {
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((ignorePrivate: boolean) => {
const deletePrivateControl = this.queueCleanerForm.get(`${section}.deletePrivate`);
if (ignorePrivate && deletePrivateControl) {
// If ignoring private, uncheck and disable delete private
deletePrivateControl.setValue(false);
deletePrivateControl.disable({ onlySelf: true });
} else if (!ignorePrivate && deletePrivateControl) {
// If not ignoring private, enable delete private (if parent section is enabled)
const sectionEnabled = this.isSectionEnabled(section);
if (sectionEnabled) {
deletePrivateControl.enable({ onlySelf: true });
}
}
});
}
});
// Listen for changes to the 'useAdvancedScheduling' control
const advancedControl = this.queueCleanerForm.get('useAdvancedScheduling');
@@ -349,6 +388,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
this.originalFormValues = JSON.parse(JSON.stringify(this.queueCleanerForm.getRawValue()));
this.hasActualChanges = false;
}
// Helper method to check if a section is enabled
private isSectionEnabled(section: string): boolean {
const mainEnabled = this.queueCleanerForm.get('enabled')?.value || false;
if (!mainEnabled) return false;
const maxStrikesControl = this.queueCleanerForm.get(`${section}.maxStrikes`);
const maxStrikes = maxStrikesControl?.value || 0;
return maxStrikes >= 3;
}
// Check if the current form values are different from the original values
private formValuesChanged(): boolean {
@@ -463,8 +513,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
if (enable) {
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.enable(options);
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.enable(options);
this.queueCleanerForm.get("failedImport")?.get("ignoredPatterns")?.enable(options);
// Only enable deletePrivate if ignorePrivate is false
const ignorePrivate = this.queueCleanerForm.get("failedImport.ignorePrivate")?.value || false;
const deletePrivateControl = this.queueCleanerForm.get("failedImport.deletePrivate");
if (!ignorePrivate && deletePrivateControl) {
deletePrivateControl.enable(options);
} else if (deletePrivateControl) {
deletePrivateControl.disable(options);
}
} else {
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.disable(options);
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.disable(options);
@@ -482,7 +541,16 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
if (enable) {
this.queueCleanerForm.get("stalled")?.get("resetStrikesOnProgress")?.enable(options);
this.queueCleanerForm.get("stalled")?.get("ignorePrivate")?.enable(options);
this.queueCleanerForm.get("stalled")?.get("deletePrivate")?.enable(options);
// Only enable deletePrivate if ignorePrivate is false
const ignorePrivate = this.queueCleanerForm.get("stalled.ignorePrivate")?.value || false;
const deletePrivateControl = this.queueCleanerForm.get("stalled.deletePrivate");
if (!ignorePrivate && deletePrivateControl) {
deletePrivateControl.enable(options);
} else if (deletePrivateControl) {
deletePrivateControl.disable(options);
}
} else {
this.queueCleanerForm.get("stalled")?.get("resetStrikesOnProgress")?.disable(options);
this.queueCleanerForm.get("stalled")?.get("ignorePrivate")?.disable(options);
@@ -500,10 +568,19 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
if (enable) {
this.queueCleanerForm.get("slow")?.get("resetStrikesOnProgress")?.enable(options);
this.queueCleanerForm.get("slow")?.get("ignorePrivate")?.enable(options);
this.queueCleanerForm.get("slow")?.get("deletePrivate")?.enable(options);
this.queueCleanerForm.get("slow")?.get("minSpeed")?.enable(options);
this.queueCleanerForm.get("slow")?.get("maxTime")?.enable(options);
this.queueCleanerForm.get("slow")?.get("ignoreAboveSize")?.enable(options);
// Only enable deletePrivate if ignorePrivate is false
const ignorePrivate = this.queueCleanerForm.get("slow.ignorePrivate")?.value || false;
const deletePrivateControl = this.queueCleanerForm.get("slow.deletePrivate");
if (!ignorePrivate && deletePrivateControl) {
deletePrivateControl.enable(options);
} else if (deletePrivateControl) {
deletePrivateControl.disable(options);
}
} else {
this.queueCleanerForm.get("slow")?.get("resetStrikesOnProgress")?.disable(options);
this.queueCleanerForm.get("slow")?.get("ignorePrivate")?.disable(options);

View File

@@ -1,28 +0,0 @@
<div class="settings-container">
<!-- Toast for notifications -->
<p-toast></p-toast>
<div class="flex align-items-center justify-content-between mb-4">
<h1>Settings</h1>
</div>
<!-- General Settings Component -->
<div class="mb-4">
<app-general-settings></app-general-settings>
</div>
<!-- Queue Cleaner Component -->
<div class="mb-4">
<app-queue-cleaner-settings></app-queue-cleaner-settings>
</div>
<!-- Content Blocker Component -->
<div class="mb-4">
<app-content-blocker-settings></app-content-blocker-settings>
</div>
<!-- Download Cleaner Component -->
<div class="mb-4">
<app-download-cleaner-settings></app-download-cleaner-settings>
</div>
</div>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SettingsPageComponent } from './settings-page.component';
describe('SettingsPageComponent', () => {
let component: SettingsPageComponent;
let fixture: ComponentFixture<SettingsPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SettingsPageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SettingsPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,84 +0,0 @@
import { Component, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { PanelModule } from 'primeng/panel';
import { ButtonModule } from 'primeng/button';
import { DropdownModule } from 'primeng/dropdown';
import { CanComponentDeactivate } from '../../core/guards';
// PrimeNG Components
import { CardModule } from 'primeng/card';
import { ToastModule } from 'primeng/toast';
import { MessageService, ConfirmationService } from 'primeng/api';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
// Custom Components and Services
import { QueueCleanerSettingsComponent } from '../queue-cleaner/queue-cleaner-settings.component';
import { GeneralSettingsComponent } from '../general-settings/general-settings.component';
import { DownloadCleanerSettingsComponent } from '../download-cleaner/download-cleaner-settings.component';
import { SonarrSettingsComponent } from '../sonarr/sonarr-settings.component';
import { ContentBlockerSettingsComponent } from "../content-blocker/content-blocker-settings.component";
@Component({
selector: 'app-settings-page',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
PanelModule,
ButtonModule,
DropdownModule,
CardModule,
ToastModule,
ConfirmDialogModule,
QueueCleanerSettingsComponent,
GeneralSettingsComponent,
DownloadCleanerSettingsComponent,
ContentBlockerSettingsComponent
],
providers: [MessageService, ConfirmationService],
templateUrl: './settings-page.component.html',
styleUrl: './settings-page.component.scss'
})
export class SettingsPageComponent implements CanComponentDeactivate {
// Reference to the settings components
@ViewChild(QueueCleanerSettingsComponent) queueCleanerSettings!: QueueCleanerSettingsComponent;
@ViewChild(GeneralSettingsComponent) generalSettings!: GeneralSettingsComponent;
@ViewChild(DownloadCleanerSettingsComponent) downloadCleanerSettings!: DownloadCleanerSettingsComponent;
@ViewChild(SonarrSettingsComponent) sonarrSettings!: SonarrSettingsComponent;
ngOnInit(): void {
// Future implementation for other settings sections
}
/**
* Implements CanComponentDeactivate interface
* Check if any settings components have unsaved changes
*/
canDeactivate(): boolean {
// Check if queue cleaner settings has unsaved changes
if (this.queueCleanerSettings?.canDeactivate() === false) {
return false;
}
// Check if general settings has unsaved changes
if (this.generalSettings?.canDeactivate() === false) {
return false;
}
// Check if download cleaner settings has unsaved changes
if (this.downloadCleanerSettings?.canDeactivate() === false) {
return false;
}
// Check if sonarr settings has unsaved changes
if (this.sonarrSettings?.canDeactivate() === false) {
return false;
}
return true;
}
}

View File

@@ -9,9 +9,9 @@
margin-right: 0.5rem;
color: var(--primary-color, #3b82f6);
cursor: pointer;
opacity: 0.7;
opacity: 0.8;
transition: opacity 0.2s ease, color 0.2s ease;
font-size: 0.9em;
font-size: 1em;
&:hover {
opacity: 1;

View File

@@ -4,7 +4,9 @@ import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModu
import { InputNumberModule } from 'primeng/inputnumber';
import { SelectButtonModule } from 'primeng/selectbutton';
type ByteSizeUnit = 'KB' | 'MB' | 'GB' | 'TB';
export type ByteSizeInputType = 'speed' | 'size';
type ByteSizeUnit = 'KB' | 'MB' | 'GB';
@Component({
selector: 'app-byte-size-input',
@@ -26,31 +28,57 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
@Input() disabled: boolean = false;
@Input() placeholder: string = 'Enter size';
@Input() helpText: string = '';
@Input() type: ByteSizeInputType = 'size';
// Value in the selected unit
value = signal<number | null>(null);
// The selected unit
unit = signal<ByteSizeUnit>('MB');
// Available units
unitOptions = [
{ label: 'KB', value: 'KB' },
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' },
{ label: 'TB', value: 'TB' }
];
// Available units, computed based on type
get unitOptions() {
switch (this.type) {
case 'speed':
return [
{ label: 'KB/s', value: 'KB' },
{ label: 'MB/s', value: 'MB' }
];
case 'size':
default:
return [
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' }
];
}
}
// Get default unit based on type
private getDefaultUnit(): ByteSizeUnit {
switch (this.type) {
case 'speed':
return 'KB';
case 'size':
default:
return 'MB';
}
}
// ControlValueAccessor interface methods
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
ngOnInit(): void {
this.unit.set(this.getDefaultUnit());
}
/**
* Parse the string value in format '100MB', '1.5GB', etc.
*/
writeValue(value: string): void {
if (!value) {
this.value.set(null);
this.unit.set(this.getDefaultUnit());
return;
}
@@ -62,15 +90,24 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
if (match) {
const numValue = parseFloat(match[1]);
const unit = match[2].toUpperCase() as ByteSizeUnit;
this.value.set(numValue);
this.unit.set(unit);
// Validate unit is allowed for this type
const allowedUnits = this.unitOptions.map(opt => opt.value);
if (allowedUnits.includes(unit)) {
this.value.set(numValue);
this.unit.set(unit);
} else {
// If unit not allowed, use default
this.value.set(numValue);
this.unit.set(this.getDefaultUnit());
}
} else {
this.value.set(null);
this.unit.set(this.getDefaultUnit());
}
} catch (e) {
console.error('Error parsing byte size value:', value, e);
this.value.set(null);
this.unit.set(this.getDefaultUnit());
}
}
@@ -91,12 +128,10 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
*/
updateValue(): void {
this.onTouched();
if (this.value() === null) {
this.onChange('');
return;
}
// Format as "100MB", "1.5GB", etc.
const formattedValue = `${this.value()}${this.unit()}`;
this.onChange(formattedValue);

Some files were not shown because too many files have changed in this diff Show More