Compare commits

...

11 Commits

Author SHA1 Message Date
Flaminel
b834078c11 updated qBit package 2025-08-14 23:32:08 +03:00
Flaminel
9cc36c7a50 Add qBittorrent basic auth support (#246) 2025-08-11 10:52:44 +03:00
Flaminel
861c135cc6 fixed Malware Blocker docs path 2025-08-07 11:55:46 +03:00
Flaminel
3b0275c411 Finish rebranding Content Blocker to Malware Blocker (#271) 2025-08-06 22:55:39 +03:00
Flaminel
cad1b51202 Improve logs and events ordering to be descending from the top (#270) 2025-08-06 22:51:20 +03:00
Flaminel
f50acd29f4 Disable MassTransit telemetry (#268) 2025-08-06 22:50:48 +03:00
LucasFA
af11d595d8 Fix detailed installation docs (#260)
https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed
2025-08-06 22:49:14 +03:00
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
67 changed files with 1371 additions and 820 deletions

View File

@@ -15,7 +15,8 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads that are **failing to be imported** by the arrs.
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).

View File

@@ -11,9 +11,9 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Mapster;
@@ -65,8 +65,8 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("content_blocker")]
public async Task<IActionResult> GetContentBlockerConfig()
[HttpGet("malware_blocker")]
public async Task<IActionResult> GetMalwareBlockerConfig()
{
await DataContext.Lock.WaitAsync();
try
@@ -483,8 +483,8 @@ public class ConfigurationController : ControllerBase
}
}
[HttpPut("content_blocker")]
public async Task<IActionResult> UpdateContentBlockerConfig([FromBody] ContentBlockerConfig newConfig)
[HttpPut("malware_blocker")]
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] ContentBlockerConfig newConfig)
{
await DataContext.Lock.WaitAsync();
try
@@ -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,13 +513,13 @@ 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" });
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save ContentBlocker configuration");
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
throw;
}
finally

View File

@@ -87,10 +87,6 @@ public class EventsController : ControllerBase
.Take(pageSize)
.ToListAsync();
events = events
.OrderBy(e => e.Timestamp)
.ToList();
// Return paginated result
var result = new PaginatedResult<AppEvent>
{

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

@@ -27,6 +27,8 @@ public static class MainDI
.AddNotifications(configuration)
.AddMassTransit(config =>
{
config.DisableUsageTelemetry();
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();

View File

@@ -1,9 +1,8 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
@@ -11,6 +10,7 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
@@ -40,7 +40,7 @@ public static class ServicesDI
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<ContentBlocker>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()

View File

@@ -1,12 +1,12 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
@@ -94,7 +94,7 @@ public class BackgroundJobManager : IHostedService
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
ContentBlockerConfig contentBlockerConfig = await dataContext.ContentBlockerConfigs
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
@@ -103,7 +103,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
}
@@ -127,17 +127,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
/// </summary>
public async Task RegisterContentBlockerJob(
public async Task RegisterMalwareBlockerJob(
ContentBlockerConfig config,
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);
}
}
@@ -190,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

@@ -3,29 +3,29 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Application.Features.ContentBlocker;
namespace Cleanuparr.Application.Features.MalwareBlocker;
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,

View File

@@ -1,8 +1,8 @@
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
public class FilenameEvaluatorFixture
{

View File

@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Enums;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Verticals.ContentBlocker;
namespace Cleanuparr.Infrastructure.Tests.Verticals.MalwareBlocker;
public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture>
{

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
<PackageReference Include="FLM.QBittorrent" Version="1.0.3.1-alpha" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />

View File

@@ -1,9 +1,9 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -4,7 +4,7 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
@@ -35,9 +35,9 @@ public partial class DelugeService
result.IsPrivate = download.Private;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && download.Private)
if (malwareBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -81,7 +81,7 @@ public partial class DelugeService
return;
}
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;

View File

@@ -2,10 +2,10 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Cache;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;

View File

@@ -1,8 +1,8 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using QBittorrent.Client;
@@ -49,9 +49,9 @@ public partial class QBitService
result.IsPrivate = isPrivate;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && isPrivate)
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -86,7 +86,7 @@ public partial class QBitService
totalFiles++;
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC.Entity;
@@ -40,9 +40,9 @@ public partial class TransmissionService
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && isPrivate)
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -69,7 +69,7 @@ public partial class TransmissionService
totalFiles++;
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
if (malwareBlockerConfig.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

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;

View File

@@ -5,7 +5,7 @@ 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 Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
@@ -38,9 +38,9 @@ public partial class UTorrentService
return result;
}
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && result.IsPrivate)
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -66,7 +66,7 @@ public partial class UTorrentService
for (int i = 0; i < files.Count; i++)
{
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;

View File

@@ -9,9 +9,9 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr;
using MassTransit;

View File

@@ -6,15 +6,14 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public sealed class BlocklistProvider
{
@@ -49,81 +48,81 @@ public sealed class BlocklistProvider
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
int changedCount = 0;
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync();
if (!contentBlockerConfig.Enabled)
if (!malwareBlockerConfig.Enabled)
{
_logger.LogDebug("Content blocker is disabled, skipping blocklist loading");
_logger.LogDebug("Malware Blocker is disabled, skipping blocklist loading");
return;
}
// Check and update Sonarr blocklist if needed
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
var sonarrInterval = GetLoadInterval(contentBlockerConfig.Sonarr.BlocklistPath);
var sonarrIdentifier = $"Sonarr_{contentBlockerConfig.Sonarr.BlocklistPath}";
string sonarrHash = GenerateSettingsHash(malwareBlockerConfig.Sonarr);
var sonarrInterval = GetLoadInterval(malwareBlockerConfig.Sonarr.BlocklistPath);
var sonarrIdentifier = $"Sonarr_{malwareBlockerConfig.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);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Sonarr, InstanceType.Sonarr);
_configHashes[InstanceType.Sonarr] = sonarrHash;
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Radarr blocklist if needed
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
var radarrInterval = GetLoadInterval(contentBlockerConfig.Radarr.BlocklistPath);
var radarrIdentifier = $"Radarr_{contentBlockerConfig.Radarr.BlocklistPath}";
string radarrHash = GenerateSettingsHash(malwareBlockerConfig.Radarr);
var radarrInterval = GetLoadInterval(malwareBlockerConfig.Radarr.BlocklistPath);
var radarrIdentifier = $"Radarr_{malwareBlockerConfig.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);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Radarr, InstanceType.Radarr);
_configHashes[InstanceType.Radarr] = radarrHash;
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
var lidarrInterval = GetLoadInterval(contentBlockerConfig.Lidarr.BlocklistPath);
var lidarrIdentifier = $"Lidarr_{contentBlockerConfig.Lidarr.BlocklistPath}";
string lidarrHash = GenerateSettingsHash(malwareBlockerConfig.Lidarr);
var lidarrInterval = GetLoadInterval(malwareBlockerConfig.Lidarr.BlocklistPath);
var lidarrIdentifier = $"Lidarr_{malwareBlockerConfig.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);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Lidarr, InstanceType.Lidarr);
_configHashes[InstanceType.Lidarr] = lidarrHash;
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Readarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
var readarrInterval = GetLoadInterval(contentBlockerConfig.Readarr.BlocklistPath);
var readarrIdentifier = $"Readarr_{contentBlockerConfig.Readarr.BlocklistPath}";
string readarrHash = GenerateSettingsHash(malwareBlockerConfig.Readarr);
var readarrInterval = GetLoadInterval(malwareBlockerConfig.Readarr.BlocklistPath);
var readarrIdentifier = $"Readarr_{malwareBlockerConfig.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);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
var whisparrInterval = GetLoadInterval(contentBlockerConfig.Whisparr.BlocklistPath);
var whisparrIdentifier = $"Whisparr_{contentBlockerConfig.Whisparr.BlocklistPath}";
string whisparrHash = GenerateSettingsHash(malwareBlockerConfig.Whisparr);
var whisparrInterval = GetLoadInterval(malwareBlockerConfig.Whisparr.BlocklistPath);
var whisparrIdentifier = $"Whisparr_{malwareBlockerConfig.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);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
changedCount++;

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public class FilenameEvaluator : IFilenameEvaluator
{

View File

@@ -2,7 +2,7 @@
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public interface IFilenameEvaluator
{

View File

@@ -1,125 +0,0 @@
# Enhanced Logging System
## Overview
The enhanced logging system provides a structured approach to logging with the following features:
- **Category-based logging**: Organize logs by functional areas (SYSTEM, API, JOBS, etc.)
- **Job name context**: Add job name to logs for background operations
- **Instance context**: Add instance names (Sonarr, Radarr, etc.) to relevant logs
- **Multiple output targets**: Console, files, and real-time SignalR streaming
- **Category-specific log files**: Separate log files for different categories
## Using the Logging System
### Adding Category to Logs
```csharp
// Using category constants
logger.WithCategory(LoggingCategoryConstants.System)
.LogInformation("This is a system log");
// Using direct category name
logger.WithCategory("API")
.LogInformation("This is an API log");
```
### Adding Job Name Context
```csharp
logger.WithCategory(LoggingCategoryConstants.Jobs)
.WithJob("ContentBlocker")
.LogInformation("Starting content blocking job");
```
### Adding Instance Name Context
```csharp
logger.WithCategory(LoggingCategoryConstants.Sonarr)
.WithInstance("Sonarr")
.LogInformation("Processing Sonarr data");
```
### Combined Context Example
```csharp
logger.WithCategory(LoggingCategoryConstants.Jobs)
.WithJob("QueueCleaner")
.WithInstance("Radarr")
.LogInformation("Cleaning Radarr queue");
```
## Log Storage
Logs are stored in the following locations:
- **Main log file**: `{config_path}/logs/Cleanuparr-.txt`
- **Category logs**: `{config_path}/logs/{category}-.txt` (e.g., `system-.txt`, `api-.txt`)
The log files use rolling file behavior:
- Daily rotation
- 10MB size limit for main log files
- 5MB size limit for category-specific logs
## SignalR Integration
The logging system includes real-time streaming via SignalR:
- **Hub URL**: `/hubs/logs`
- **Hub class**: `LogHub`
- **Event name**: `ReceiveLog`
### Requesting Recent Logs
When a client connects, it can request recent logs from the buffer:
```javascript
await connection.invoke("RequestRecentLogs");
```
### Log Message Format
Each log message contains:
- `timestamp`: The time the log was created
- `level`: Log level (Information, Warning, Error, etc.)
- `message`: The log message text
- `exception`: Exception details (if present)
- `category`: The log category
- `jobName`: The job name (if present)
- `instanceName`: The instance name (if present)
## How It All Works
1. The logging system is initialized during application startup
2. Logs are written to the console in real-time
3. Logs are written to files based on their category
4. Logs are buffered and sent to connected SignalR clients
5. New clients can request recent logs from the buffer
## Configuration Options
The logging configuration is loaded from the `Logging` section in appsettings.json:
```json
{
"Logging": {
"LogLevel": "Information",
"SignalR": {
"Enabled": true,
"BufferSize": 100
}
}
}
```
## Standard Categories
Use the `LoggingCategoryConstants` class to ensure consistent category naming:
- `LoggingCategoryConstants.System`: System-level logs
- `LoggingCategoryConstants.Api`: API-related logs
- `LoggingCategoryConstants.Jobs`: Job execution logs
- `LoggingCategoryConstants.Notifications`: User notification logs
- `LoggingCategoryConstants.Sonarr`: Sonarr-related logs
- `LoggingCategoryConstants.Radarr`: Radarr-related logs
- `LoggingCategoryConstants.Lidarr`: Lidarr-related logs

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

@@ -2,9 +2,9 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Converters;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
/// <summary>
/// Settings for a blocklist

View File

@@ -2,7 +2,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
public sealed record ContentBlockerConfig : IJobConfig
{

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: 'malware-blocker',
loadComponent: () => import('./settings/malware-blocker/malware-blocker-settings.component').then(m => m.MalwareBlockerSettingsComponent),
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

@@ -2,7 +2,7 @@ import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { Observable, catchError, map, throwError } from "rxjs";
import { JobSchedule, QueueCleanerConfig, ScheduleUnit } from "../../shared/models/queue-cleaner-config.model";
import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, ScheduleUnit as ContentBlockerScheduleUnit } from "../../shared/models/content-blocker-config.model";
import { MalwareBlockerConfig as MalwareBlockerConfig, JobSchedule as MalwareBlockerJobSchedule, ScheduleUnit as MalwareBlockerScheduleUnit } from "../../shared/models/malware-blocker-config.model";
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
@@ -81,15 +81,15 @@ export class ConfigurationService {
/**
* Get content blocker configuration
*/
getContentBlockerConfig(): Observable<ContentBlockerConfig> {
return this.http.get<ContentBlockerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/content_blocker')).pipe(
getMalwareBlockerConfig(): Observable<MalwareBlockerConfig> {
return this.http.get<MalwareBlockerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/malware_blocker')).pipe(
map((response) => {
response.jobSchedule = this.tryExtractContentBlockerJobScheduleFromCron(response.cronExpression);
response.jobSchedule = this.tryExtractMalwareBlockerJobScheduleFromCron(response.cronExpression);
return response;
}),
catchError((error) => {
console.error("Error fetching content blocker config:", error);
return throwError(() => new Error("Failed to load content blocker configuration"));
console.error("Error fetching Malware Blocker config:", error);
return throwError(() => new Error("Failed to load Malware Blocker configuration"));
})
);
}
@@ -97,14 +97,14 @@ export class ConfigurationService {
/**
* Update content blocker configuration
*/
updateContentBlockerConfig(config: ContentBlockerConfig): Observable<void> {
updateMalwareBlockerConfig(config: MalwareBlockerConfig): Observable<void> {
// Generate cron expression if using basic scheduling
if (!config.useAdvancedScheduling && config.jobSchedule) {
config.cronExpression = this.convertContentBlockerJobScheduleToCron(config.jobSchedule);
config.cronExpression = this.convertMalwareBlockerJobScheduleToCron(config.jobSchedule);
}
return this.http.put<void>(this.ApplicationPathService.buildApiUrl('/configuration/content_blocker'), config).pipe(
return this.http.put<void>(this.ApplicationPathService.buildApiUrl('/configuration/malware_blocker'), config).pipe(
catchError((error) => {
console.error("Error updating content blocker config:", error);
console.error("Error updating Malware Blocker config:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
@@ -188,10 +188,10 @@ export class ConfigurationService {
}
/**
* Try to extract a ContentBlockerJobSchedule from a cron expression
* Try to extract a MalwareBlockerJobSchedule from a cron expression
* Only handles the simple cases we're generating
*/
private tryExtractContentBlockerJobScheduleFromCron(cronExpression: string): ContentBlockerJobSchedule | undefined {
private tryExtractMalwareBlockerJobScheduleFromCron(cronExpression: string): MalwareBlockerJobSchedule | undefined {
// Patterns we support:
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
@@ -205,7 +205,7 @@ export class ConfigurationService {
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
const seconds = parseInt(parts[0].substring(2));
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
return { every: seconds, type: ContentBlockerScheduleUnit.Seconds };
return { every: seconds, type: MalwareBlockerScheduleUnit.Seconds };
}
}
@@ -213,7 +213,7 @@ export class ConfigurationService {
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
const minutes = parseInt(parts[1].substring(2));
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
return { every: minutes, type: ContentBlockerScheduleUnit.Minutes };
return { every: minutes, type: MalwareBlockerScheduleUnit.Minutes };
}
}
@@ -221,7 +221,7 @@ export class ConfigurationService {
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
const hours = parseInt(parts[2].substring(2));
if (!isNaN(hours) && hours > 0 && hours < 24) {
return { every: hours, type: ContentBlockerScheduleUnit.Hours };
return { every: hours, type: MalwareBlockerScheduleUnit.Hours };
}
}
} catch (e) {
@@ -232,27 +232,27 @@ export class ConfigurationService {
}
/**
* Convert a ContentBlockerJobSchedule to a cron expression
* Convert a MalwareBlockerJobSchedule to a cron expression
*/
private convertContentBlockerJobScheduleToCron(schedule: ContentBlockerJobSchedule): string {
private convertMalwareBlockerJobScheduleToCron(schedule: MalwareBlockerJobSchedule): string {
if (!schedule || schedule.every <= 0) {
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
switch (schedule.type) {
case ContentBlockerScheduleUnit.Seconds:
case MalwareBlockerScheduleUnit.Seconds:
if (schedule.every < 60) {
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Minutes:
case MalwareBlockerScheduleUnit.Minutes:
if (schedule.every < 60) {
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Hours:
case MalwareBlockerScheduleUnit.Hours:
if (schedule.every < 24) {
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
}

View File

@@ -63,8 +63,8 @@ export class DocumentationService {
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories'
},
'content-blocker': {
'enabled': 'enable-content-blocker',
'malware-blocker': {
'enabled': 'enable-malware-blocker',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'jobSchedule.every': 'run-schedule',

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: '/malware-blocker', navigationPath: ['settings', 'malware-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: 'malware-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/malware-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

@@ -11,14 +11,6 @@
></p-tag>
</div>
<div class="log-controls flex align-items-center gap-2">
<div class="auto-scroll-toggle">
<p-inputSwitch
[ngModel]="autoScroll()"
(ngModelChange)="setAutoScroll($event)"
id="autoScrollToggle"
></p-inputSwitch>
<label for="autoScrollToggle" class="ml-2 text-sm">Auto-scroll</label>
</div>
<button
pButton
icon="pi pi-download"
@@ -112,7 +104,7 @@
<div class="card-content">
<!-- Console-style Logs View -->
<div class="viewer-console" #logsConsole>
<div class="viewer-console">
<!-- Logs List -->
<div class="items-list" *ngIf="filteredLogs().length > 0; else emptyLogs">
<div *ngFor="let log of filteredLogs(); let i = index" class="item-entry" [id]="'log-' + i">

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, signal, computed, inject, ViewChild, ElementRef } from '@angular/core';
import { Component, OnInit, OnDestroy, signal, computed, inject, ViewChild } from '@angular/core';
import { DatePipe, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs';
@@ -14,7 +14,6 @@ import { CardModule } from 'primeng/card';
import { ToolbarModule } from 'primeng/toolbar';
import { TooltipModule } from 'primeng/tooltip';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { InputSwitchModule } from 'primeng/inputswitch';
// Services & Models
import { AppHubService } from '../../core/services/app-hub.service';
@@ -39,8 +38,7 @@ import { MenuItem } from 'primeng/api';
ToolbarModule,
TooltipModule,
ProgressSpinnerModule,
MenuModule,
InputSwitchModule
MenuModule
],
providers: [AppHubService],
templateUrl: './logs-viewer.component.html',
@@ -52,13 +50,11 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
private clipboard = inject(Clipboard);
private search$ = new Subject<string>();
@ViewChild('logsConsole') logsConsole!: ElementRef;
@ViewChild('exportMenu') exportMenu: any;
// Signals for reactive state
logs = signal<LogEntry[]>([]);
isConnected = signal<boolean>(false);
autoScroll = signal<boolean>(true);
expandedLogs: { [key: number]: boolean } = {};
// Filter state
@@ -92,7 +88,8 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
(log.exception && log.exception.toLowerCase().includes(search)));
}
return filtered;
// Sort by timestamp descending (newest first)
return filtered.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
});
levels = computed(() => {
@@ -117,9 +114,6 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe((logs: LogEntry[]) => {
this.logs.set(logs);
if (this.autoScroll()) {
this.scrollToBottom();
}
});
// Subscribe to connection status
@@ -141,12 +135,6 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
});
}
ngAfterViewChecked(): void {
if (this.autoScroll() && this.logsConsole) {
this.scrollToBottom();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
@@ -345,24 +333,4 @@ export class LogsViewerComponent implements OnInit, OnDestroy {
URL.revokeObjectURL(url);
}, 100);
}
/**
* Scroll to the bottom of the logs container
*/
private scrollToBottom(): void {
if (this.logsConsole && this.logsConsole.nativeElement) {
const element = this.logsConsole.nativeElement;
element.scrollTop = element.scrollHeight;
}
}
/**
* Sets the auto-scroll state
*/
setAutoScroll(value: boolean): void {
this.autoScroll.set(value);
if (value) {
this.scrollToBottom();
}
}
}

View File

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

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
@@ -281,7 +284,7 @@
<!-- 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
@@ -295,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
@@ -311,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
@@ -371,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

@@ -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,7 +146,7 @@
<div class="field">
<label for="client-type">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('typeName')"
pTooltip="Click for documentation"></i>
Client Type *
@@ -167,7 +167,7 @@
<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

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

@@ -1,20 +1,20 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ContentBlockerConfig, JobSchedule, ScheduleUnit } from '../../shared/models/content-blocker-config.model';
import { MalwareBlockerConfig as MalwareBlockerConfig, JobSchedule, ScheduleUnit } from '../../shared/models/malware-blocker-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, throwError } from 'rxjs';
import { ErrorHandlerUtil } from '../../core/utils/error-handler.util';
export interface ContentBlockerConfigState {
config: ContentBlockerConfig | null;
export interface MalwareBlockerConfigState {
config: MalwareBlockerConfig | null;
loading: boolean;
saving: boolean;
loadError: string | null; // Only for load failures that should show "Not connected"
saveError: string | null; // Only for save failures that should show toast
}
const initialState: ContentBlockerConfigState = {
const initialState: MalwareBlockerConfigState = {
config: null,
loading: false,
saving: false,
@@ -23,17 +23,17 @@ const initialState: ContentBlockerConfigState = {
};
@Injectable()
export class ContentBlockerConfigStore extends signalStore(
export class MalwareBlockerConfigStore extends signalStore(
withState(initialState),
withMethods((store, configService = inject(ConfigurationService)) => ({
/**
* Load the content blocker configuration
* Load the malware blocker configuration
*/
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, loadError: null, saveError: null })),
switchMap(() => configService.getContentBlockerConfig().pipe(
switchMap(() => configService.getMalwareBlockerConfig().pipe(
tap({
next: (config) => patchState(store, { config, loading: false, loadError: null }),
error: (error) => {
@@ -59,10 +59,10 @@ export class ContentBlockerConfigStore extends signalStore(
/**
* Save the content blocker configuration
*/
saveConfig: rxMethod<ContentBlockerConfig>(
(config$: Observable<ContentBlockerConfig>) => config$.pipe(
saveConfig: rxMethod<MalwareBlockerConfig>(
(config$: Observable<MalwareBlockerConfig>) => config$.pipe(
tap(() => patchState(store, { saving: true, saveError: null })),
switchMap(config => configService.updateContentBlockerConfig(config).pipe(
switchMap(config => configService.updateMalwareBlockerConfig(config).pipe(
tap({
next: () => {
// Don't set config - let the form stay as-is with string enum values
@@ -94,7 +94,7 @@ export class ContentBlockerConfigStore extends signalStore(
/**
* Update config in the store without saving to the backend
*/
updateConfigLocally(config: Partial<ContentBlockerConfig>) {
updateConfigLocally(config: Partial<MalwareBlockerConfig>) {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {

View File

@@ -1,45 +1,48 @@
<!-- 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">
<!-- Loading/Error State Component -->
<app-loading-error-state
*ngIf="contentBlockerLoading() || contentBlockerLoadError()"
[loading]="contentBlockerLoading()"
[error]="contentBlockerLoadError()"
*ngIf="malwareBlockerLoading() || malwareBlockerLoadError()"
[loading]="malwareBlockerLoading()"
[error]="malwareBlockerLoadError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Form Content - only shown when not loading and no error -->
<form *ngIf="!contentBlockerLoading() && !contentBlockerLoadError()" [formGroup]="contentBlockerForm" class="p-fluid">
<form *ngIf="!malwareBlockerLoading() && !malwareBlockerLoadError()" [formGroup]="malwareBlockerForm" class="p-fluid">
<!-- 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
@@ -59,7 +62,7 @@
</div>
<!-- Basic Schedule Controls - shown when useAdvancedScheduling is false -->
<div class="field-row" formGroupName="jobSchedule" *ngIf="!contentBlockerForm.get('useAdvancedScheduling')?.value">
<div class="field-row" formGroupName="jobSchedule" *ngIf="!malwareBlockerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
Run Schedule
</label>
@@ -85,14 +88,14 @@
</p-selectButton>
</div>
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
<small class="form-helper-text">How often the content blocker should run</small>
<small class="form-helper-text">How often the Malware Blocker should run</small>
</div>
</div>
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
<div class="field-row" *ngIf="malwareBlockerForm.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,7 +112,7 @@
<!-- 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
@@ -122,34 +125,34 @@
<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">Enable this if you want to keep private torrents in the download client even if they are removed from the arrs</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-info-circle field-info-icon"
<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, files matching known malware patterns will be deleted</small>
<small class="form-helper-text">When enabled, downloads matching known malware patterns will be deleted</small>
</div>
</div>
<!-- Arr Service Settings in Accordion -->
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Sonarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="0">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -164,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
@@ -177,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
@@ -193,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
@@ -219,7 +222,7 @@
</p-accordion-panel>
<!-- Radarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="1">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -234,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
@@ -247,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
@@ -263,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
@@ -289,7 +292,7 @@
</p-accordion-panel>
<!-- Lidarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="2">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="2">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -304,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
@@ -317,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
@@ -333,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
@@ -359,7 +362,7 @@
</p-accordion-panel>
<!-- Readarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -374,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
@@ -387,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
@@ -403,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
@@ -429,7 +432,7 @@
</p-accordion-panel>
<!-- Whisparr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="4">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="4">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -444,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
@@ -457,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
@@ -473,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
@@ -507,9 +510,9 @@
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!contentBlockerForm.dirty || !hasActualChanges) || contentBlockerForm.invalid || contentBlockerSaving()"
[loading]="contentBlockerSaving()"
(click)="saveContentBlockerConfig()"
[disabled]="(!malwareBlockerForm.dirty || !hasActualChanges) || malwareBlockerForm.invalid || malwareBlockerSaving()"
[loading]="malwareBlockerSaving()"
(click)="saveMalwareBlockerConfig()"
></button>
<button
pButton
@@ -517,9 +520,10 @@
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetContentBlockerConfig()"
(click)="resetMalwareBlockerConfig()"
></button>
</div>
</form>
</div>
</p-card>
</p-card>
</div>

View File

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

View File

@@ -2,14 +2,14 @@ import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@ang
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ContentBlockerConfigStore } from "./content-blocker-config.store";
import { MalwareBlockerConfigStore } from "./malware-blocker-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import {
ContentBlockerConfig,
MalwareBlockerConfig,
ScheduleUnit,
BlocklistType,
ScheduleOptions
} from "../../shared/models/content-blocker-config.model";
} from "../../shared/models/malware-blocker-config.model";
import { FluidModule } from 'primeng/fluid';
@@ -30,7 +30,7 @@ import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
import { DocumentationService } from "../../core/services/documentation.service";
@Component({
selector: "app-content-blocker-settings",
selector: "app-malware-blocker-settings",
standalone: true,
imports: [
CommonModule,
@@ -47,16 +47,16 @@ import { DocumentationService } from "../../core/services/documentation.service"
LoadingErrorStateComponent,
FluidModule,
],
providers: [ContentBlockerConfigStore],
templateUrl: "./content-blocker-settings.component.html",
styleUrls: ["./content-blocker-settings.component.scss"],
providers: [MalwareBlockerConfigStore],
templateUrl: "./malware-blocker-settings.component.html",
styleUrls: ["./malware-blocker-settings.component.scss"],
})
export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentDeactivate {
export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentDeactivate {
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Content Blocker Configuration Form
contentBlockerForm: FormGroup;
malwareBlockerForm: FormGroup;
// Original form values for tracking changes
private originalFormValues: any;
@@ -88,15 +88,15 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
private formBuilder = inject(FormBuilder);
// Using the notification service for all toast messages
private notificationService = inject(NotificationService);
private contentBlockerStore = inject(ContentBlockerConfigStore);
private malwareBlockerStore = inject(MalwareBlockerConfigStore);
private documentationService = inject(DocumentationService);
// Signals from the store
readonly contentBlockerConfig = this.contentBlockerStore.config;
readonly contentBlockerLoading = this.contentBlockerStore.loading;
readonly contentBlockerSaving = this.contentBlockerStore.saving;
readonly contentBlockerLoadError = this.contentBlockerStore.loadError; // Only for "Not connected" state
readonly contentBlockerSaveError = this.contentBlockerStore.saveError; // Only for toast notifications
readonly malwareBlockerConfig = this.malwareBlockerStore.config;
readonly malwareBlockerLoading = this.malwareBlockerStore.loading;
readonly malwareBlockerSaving = this.malwareBlockerStore.saving;
readonly malwareBlockerLoadError = this.malwareBlockerStore.loadError; // Only for "Not connected" state
readonly malwareBlockerSaveError = this.malwareBlockerStore.saveError; // Only for toast notifications
// Track active accordion tabs
activeAccordionIndices: number[] = [];
@@ -108,7 +108,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.contentBlockerForm.dirty;
return !this.malwareBlockerForm.dirty;
}
/**
@@ -116,12 +116,12 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* @param fieldName Field name to open documentation for
*/
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('content-blocker', fieldName);
this.documentationService.openFieldDocumentation('malware-blocker', fieldName);
}
constructor() {
// Initialize the content blocker form with proper disabled states
this.contentBlockerForm = this.formBuilder.group({
this.malwareBlockerForm = this.formBuilder.group({
enabled: [false],
useAdvancedScheduling: [{ value: false, disabled: true }],
cronExpression: [{ value: '', disabled: true }, [Validators.required]],
@@ -164,7 +164,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
// Create an effect to update the form when the configuration changes
effect(() => {
const config = this.contentBlockerConfig();
const config = this.malwareBlockerConfig();
if (config) {
// Handle the case where ignorePrivate is true but deletePrivate is also true
// This shouldn't happen, but if it does, correct it
@@ -176,7 +176,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Reset form with the corrected config values
this.contentBlockerForm.patchValue({
this.malwareBlockerForm.patchValue({
enabled: correctedConfig.enabled,
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
cronExpression: correctedConfig.cronExpression,
@@ -201,13 +201,13 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.storeOriginalValues();
// Mark form as pristine since we've just loaded the data
this.contentBlockerForm.markAsPristine();
this.malwareBlockerForm.markAsPristine();
}
});
// Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display
effect(() => {
const loadErrorMessage = this.contentBlockerLoadError();
const loadErrorMessage = this.malwareBlockerLoadError();
if (loadErrorMessage) {
// Load errors should be shown as "Not connected to server" in LoadingErrorStateComponent
this.error.emit(loadErrorMessage);
@@ -216,7 +216,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
// Effect to handle save errors - show as toast notifications for user to fix
effect(() => {
const saveErrorMessage = this.contentBlockerSaveError();
const saveErrorMessage = this.malwareBlockerSaveError();
if (saveErrorMessage) {
// Check if this looks like a validation error from the backend
// These are typically user-fixable errors that should be shown as toasts
@@ -249,7 +249,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
*/
private setupFormValueChangeListeners(): void {
// Listen for changes to the 'enabled' control
const enabledControl = this.contentBlockerForm.get('enabled');
const enabledControl = this.malwareBlockerForm.get('enabled');
if (enabledControl) {
enabledControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((enabled: boolean) => {
@@ -258,11 +258,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Add listener for ignorePrivate changes
const ignorePrivateControl = this.contentBlockerForm.get('ignorePrivate');
const ignorePrivateControl = this.malwareBlockerForm.get('ignorePrivate');
if (ignorePrivateControl) {
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((ignorePrivate: boolean) => {
const deletePrivateControl = this.contentBlockerForm.get('deletePrivate');
const deletePrivateControl = this.malwareBlockerForm.get('deletePrivate');
if (ignorePrivate && deletePrivateControl) {
// If ignoring private, uncheck and disable delete private
@@ -270,7 +270,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
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;
const mainEnabled = this.malwareBlockerForm.get('enabled')?.value || false;
if (mainEnabled) {
deletePrivateControl.enable({ onlySelf: true });
}
@@ -279,14 +279,14 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Listen for changes to the 'useAdvancedScheduling' control
const advancedControl = this.contentBlockerForm.get('useAdvancedScheduling');
const advancedControl = this.malwareBlockerForm.get('useAdvancedScheduling');
if (advancedControl) {
advancedControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((useAdvanced: boolean) => {
const enabled = this.contentBlockerForm.get('enabled')?.value || false;
const enabled = this.malwareBlockerForm.get('enabled')?.value || false;
if (enabled) {
const cronExpressionControl = this.contentBlockerForm.get('cronExpression');
const jobScheduleGroup = this.contentBlockerForm.get('jobSchedule') as FormGroup;
const cronExpressionControl = this.malwareBlockerForm.get('cronExpression');
const jobScheduleGroup = this.malwareBlockerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
@@ -304,15 +304,15 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Listen for changes to the schedule type to ensure dropdown isn't empty
const scheduleTypeControl = this.contentBlockerForm.get('jobSchedule.type');
const scheduleTypeControl = this.malwareBlockerForm.get('jobSchedule.type');
if (scheduleTypeControl) {
scheduleTypeControl.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
// Ensure the selected value is valid for the new type
const everyControl = this.contentBlockerForm.get('jobSchedule.every');
const everyControl = this.malwareBlockerForm.get('jobSchedule.every');
const currentValue = everyControl?.value;
const scheduleType = this.contentBlockerForm.get('jobSchedule.type')?.value;
const scheduleType = this.malwareBlockerForm.get('jobSchedule.type')?.value;
const validValues = ScheduleOptions[scheduleType as keyof typeof ScheduleOptions];
if (currentValue && !validValues.includes(currentValue)) {
@@ -323,7 +323,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
// Listen for changes to blocklist enabled states
['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'].forEach(arrType => {
const enabledControl = this.contentBlockerForm.get(`${arrType}.enabled`);
const enabledControl = this.malwareBlockerForm.get(`${arrType}.enabled`);
if (enabledControl) {
enabledControl.valueChanges.pipe(takeUntil(this.destroy$))
@@ -334,7 +334,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
});
// Listen to all form changes to check for actual differences from original values
this.contentBlockerForm.valueChanges.pipe(takeUntil(this.destroy$))
this.malwareBlockerForm.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasActualChanges = this.formValuesChanged();
});
@@ -345,7 +345,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
*/
private storeOriginalValues(): void {
// Create a deep copy of the form values to ensure proper comparison
this.originalFormValues = JSON.parse(JSON.stringify(this.contentBlockerForm.getRawValue()));
this.originalFormValues = JSON.parse(JSON.stringify(this.malwareBlockerForm.getRawValue()));
this.hasActualChanges = false;
}
@@ -353,7 +353,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
private formValuesChanged(): boolean {
if (!this.originalFormValues) return false;
const currentValues = this.contentBlockerForm.getRawValue();
const currentValues = this.malwareBlockerForm.getRawValue();
return !this.isEqual(currentValues, this.originalFormValues);
}
@@ -383,7 +383,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
/**
* Update form control disabled states based on the configuration
*/
private updateFormControlDisabledStates(config: ContentBlockerConfig): void {
private updateFormControlDisabledStates(config: MalwareBlockerConfig): void {
// Update main form controls based on the 'enabled' state
this.updateMainControlsState(config.enabled);
@@ -401,8 +401,8 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* Update the state of blocklist dependent controls based on the 'enabled' control value
*/
private updateBlocklistDependentControls(arrType: string, enabled: boolean): void {
const pathControl = this.contentBlockerForm.get(`${arrType}.blocklistPath`);
const typeControl = this.contentBlockerForm.get(`${arrType}.blocklistType`);
const pathControl = this.malwareBlockerForm.get(`${arrType}.blocklistPath`);
const typeControl = this.malwareBlockerForm.get(`${arrType}.blocklistType`);
const options = { onlySelf: true };
if (enabled) {
@@ -423,9 +423,9 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* Update the state of main controls based on the 'enabled' control value
*/
private updateMainControlsState(enabled: boolean): void {
const useAdvancedScheduling = this.contentBlockerForm.get('useAdvancedScheduling')?.value || false;
const cronExpressionControl = this.contentBlockerForm.get('cronExpression');
const jobScheduleGroup = this.contentBlockerForm.get('jobSchedule') as FormGroup;
const useAdvancedScheduling = this.malwareBlockerForm.get('useAdvancedScheduling')?.value || false;
const cronExpressionControl = this.malwareBlockerForm.get('cronExpression');
const jobScheduleGroup = this.malwareBlockerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup.get('every');
const typeControl = jobScheduleGroup.get('type');
@@ -442,16 +442,16 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Enable the useAdvancedScheduling control
const useAdvancedSchedulingControl = this.contentBlockerForm.get('useAdvancedScheduling');
const useAdvancedSchedulingControl = this.malwareBlockerForm.get('useAdvancedScheduling');
useAdvancedSchedulingControl?.enable();
// Enable content blocker specific controls
this.contentBlockerForm.get("ignorePrivate")?.enable({ onlySelf: true });
this.contentBlockerForm.get("deleteKnownMalware")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("ignorePrivate")?.enable({ onlySelf: true });
this.malwareBlockerForm.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");
const ignorePrivate = this.malwareBlockerForm.get("ignorePrivate")?.value || false;
const deletePrivateControl = this.malwareBlockerForm.get("deletePrivate");
if (!ignorePrivate && deletePrivateControl) {
deletePrivateControl.enable({ onlySelf: true });
@@ -460,18 +460,18 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Enable blocklist settings for each Arr
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("radarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("whisparr.enabled")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("radarr.enabled")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("lidarr.enabled")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("readarr.enabled")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("whisparr.enabled")?.enable({ onlySelf: true });
// Update dependent controls based on current enabled states
const sonarrEnabled = this.contentBlockerForm.get("sonarr.enabled")?.value || false;
const radarrEnabled = this.contentBlockerForm.get("radarr.enabled")?.value || false;
const lidarrEnabled = this.contentBlockerForm.get("lidarr.enabled")?.value || false;
const readarrEnabled = this.contentBlockerForm.get("readarr.enabled")?.value || false;
const whisparrEnabled = this.contentBlockerForm.get("whisparr.enabled")?.value || false;
const sonarrEnabled = this.malwareBlockerForm.get("sonarr.enabled")?.value || false;
const radarrEnabled = this.malwareBlockerForm.get("radarr.enabled")?.value || false;
const lidarrEnabled = this.malwareBlockerForm.get("lidarr.enabled")?.value || false;
const readarrEnabled = this.malwareBlockerForm.get("readarr.enabled")?.value || false;
const whisparrEnabled = this.malwareBlockerForm.get("whisparr.enabled")?.value || false;
this.updateBlocklistDependentControls('sonarr', sonarrEnabled);
this.updateBlocklistDependentControls('radarr', radarrEnabled);
@@ -485,30 +485,30 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
typeControl?.disable();
// Disable the useAdvancedScheduling control
const useAdvancedSchedulingControl = this.contentBlockerForm.get('useAdvancedScheduling');
const useAdvancedSchedulingControl = this.malwareBlockerForm.get('useAdvancedScheduling');
useAdvancedSchedulingControl?.disable();
// 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 });
this.malwareBlockerForm.get("ignorePrivate")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("deletePrivate")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("deleteKnownMalware")?.disable({ onlySelf: true });
// Disable all blocklist settings for each Arr
this.contentBlockerForm.get("sonarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("sonarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("sonarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("radarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("radarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("radarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("whisparr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("whisparr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("whisparr.blocklistType")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("sonarr.enabled")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("sonarr.blocklistPath")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("sonarr.blocklistType")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("radarr.enabled")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("radarr.blocklistPath")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("radarr.blocklistType")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("lidarr.enabled")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("lidarr.blocklistPath")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("lidarr.blocklistType")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("readarr.enabled")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("readarr.blocklistPath")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("readarr.blocklistType")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("whisparr.enabled")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("whisparr.blocklistPath")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("whisparr.blocklistType")?.disable({ onlySelf: true });
// Save current active accordion state before clearing it
this.activeAccordionIndices = [];
@@ -518,22 +518,22 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
/**
* Save the content blocker configuration
*/
saveContentBlockerConfig(): void {
saveMalwareBlockerConfig(): void {
// Mark all form controls as touched to trigger validation messages
this.markFormGroupTouched(this.contentBlockerForm);
this.markFormGroupTouched(this.malwareBlockerForm);
if (this.contentBlockerForm.valid) {
if (this.malwareBlockerForm.valid) {
// Make a copy of the form values
const formValue = this.contentBlockerForm.getRawValue();
const formValue = this.malwareBlockerForm.getRawValue();
// Create the config object to be saved
const contentBlockerConfig: ContentBlockerConfig = {
const malwareBlockerConfig: MalwareBlockerConfig = {
enabled: formValue.enabled,
useAdvancedScheduling: formValue.useAdvancedScheduling,
cronExpression: formValue.useAdvancedScheduling ?
formValue.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.contentBlockerStore.generateCronExpression(formValue.jobSchedule),
this.malwareBlockerStore.generateCronExpression(formValue.jobSchedule),
jobSchedule: formValue.jobSchedule,
ignorePrivate: formValue.ignorePrivate || false,
deletePrivate: formValue.deletePrivate || false,
@@ -566,22 +566,22 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
};
// Save the configuration
this.contentBlockerStore.saveConfig(contentBlockerConfig);
this.malwareBlockerStore.saveConfig(malwareBlockerConfig);
// Setup a one-time check to mark form as pristine after successful save
const checkSaveCompletion = () => {
const saving = this.contentBlockerSaving();
const saveError = this.contentBlockerSaveError();
const saving = this.malwareBlockerSaving();
const saveError = this.malwareBlockerSaveError();
if (!saving && !saveError) {
// Mark form as pristine after successful save
this.contentBlockerForm.markAsPristine();
this.malwareBlockerForm.markAsPristine();
// Update original values reference
this.storeOriginalValues();
// Emit saved event
this.saved.emit();
// Display success message
this.notificationService.showSuccess('Content blocker configuration saved successfully.');
this.notificationService.showSuccess('Malware Blocker configuration saved successfully.');
} else if (!saving && saveError) {
// If there's a save error, we can stop checking
// Toast notification is already handled by the effect above
@@ -605,8 +605,8 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
/**
* Reset the content blocker configuration form to default values
*/
resetContentBlockerConfig(): void {
this.contentBlockerForm.reset({
resetMalwareBlockerConfig(): void {
this.malwareBlockerForm.reset({
enabled: false,
useAdvancedScheduling: false,
cronExpression: "0/5 * * * * ?",
@@ -653,7 +653,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateBlocklistDependentControls('whisparr', false);
// Mark form as dirty so the save button is enabled after reset
this.contentBlockerForm.markAsDirty();
this.malwareBlockerForm.markAsDirty();
}
/**
@@ -673,7 +673,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* Check if a form control has an error after it's been touched
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.contentBlockerForm.get(controlName);
const control = this.malwareBlockerForm.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
}
@@ -681,7 +681,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* Get schedule value options based on the current schedule unit type
*/
getScheduleValueOptions(): {label: string, value: number}[] {
const scheduleType = this.contentBlockerForm.get('jobSchedule.type')?.value as ScheduleUnit;
const scheduleType = this.malwareBlockerForm.get('jobSchedule.type')?.value as ScheduleUnit;
if (scheduleType === ScheduleUnit.Seconds) {
return this.scheduleValueOptions[ScheduleUnit.Seconds];
} else if (scheduleType === ScheduleUnit.Minutes) {
@@ -696,7 +696,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
* Get nested form control errors
*/
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
const parentControl = this.contentBlockerForm.get(parentName);
const parentControl = this.malwareBlockerForm.get(parentName);
if (!parentControl || !(parentControl instanceof FormGroup)) {
return false;
}

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,7 +151,7 @@
<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
@@ -161,20 +164,20 @@
<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">Enable this if you want to keep private torrents in the download client even if they are removed from the arrs</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,7 +260,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.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
@@ -270,14 +273,14 @@
<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">Enable this if you want to keep private torrents in the download client even if they are removed from the arrs</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,7 +379,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.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
@@ -389,20 +392,20 @@
<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">Enable this if you want to keep private torrents in the download client even if they are removed from the arrs</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
@@ -421,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)
@@ -444,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
@@ -490,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

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

@@ -0,0 +1 @@
export * from './numeric-input.directive';

View File

@@ -0,0 +1,94 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { NumericInputDirective } from './numeric-input.directive';
@Component({
template: `
<form [formGroup]="testForm">
<input
type="text"
formControlName="channelId"
numericInput
data-testid="numeric-input"
/>
</form>
`
})
class TestComponent {
testForm = new FormGroup({
channelId: new FormControl('')
});
}
describe('NumericInputDirective', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let inputElement: HTMLInputElement;
let inputDebugElement: DebugElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TestComponent],
imports: [ReactiveFormsModule, NumericInputDirective]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
inputDebugElement = fixture.debugElement.query(By.css('[data-testid="numeric-input"]'));
inputElement = inputDebugElement.nativeElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should allow numeric input', () => {
// Simulate typing numbers
inputElement.value = '123456789';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.testForm.get('channelId')?.value).toBe('123456789');
});
it('should remove non-numeric characters', () => {
// Simulate typing mixed input
inputElement.value = '123abc456def';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.testForm.get('channelId')?.value).toBe('123456');
expect(inputElement.value).toBe('123456');
});
it('should handle Discord channel ID format', () => {
// Discord channel IDs are typically 18-19 digits
const discordChannelId = '123456789012345678';
inputElement.value = discordChannelId;
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.testForm.get('channelId')?.value).toBe(discordChannelId);
});
it('should prevent non-numeric keypress', () => {
const event = new KeyboardEvent('keydown', { keyCode: 65 }); // 'A' key
spyOn(event, 'preventDefault');
inputElement.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('should allow control keys', () => {
const backspaceEvent = new KeyboardEvent('keydown', { keyCode: 8 }); // Backspace
spyOn(backspaceEvent, 'preventDefault');
inputElement.dispatchEvent(backspaceEvent);
expect(backspaceEvent.preventDefault).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,85 @@
import { Directive, HostListener } from '@angular/core';
import { NgControl } from '@angular/forms';
/**
* Directive that restricts input to numeric characters only.
* Useful for fields that need to accept very long numeric values like Discord channel IDs
* that exceed JavaScript's safe integer limits.
*
* Usage: <input type="text" numericInput formControlName="channelId" />
*/
@Directive({
selector: '[numericInput]',
standalone: true
})
export class NumericInputDirective {
private regex = /^\d*$/; // Only allow positive integers (no decimals or negative numbers)
constructor(private ngControl: NgControl) {}
@HostListener('input', ['$event'])
onInput(event: Event): void {
const input = event.target as HTMLInputElement;
const originalValue = input.value;
if (!this.regex.test(originalValue)) {
// Strip all non-numeric characters
const sanitized = originalValue.replace(/[^\d]/g, '');
// Update the form control value
this.ngControl.control?.setValue(sanitized);
// Update the input display value
input.value = sanitized;
}
}
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent): void {
// Allow: backspace, delete, tab, escape, enter
if ([8, 9, 27, 13, 46].indexOf(event.keyCode) !== -1 ||
// Allow: Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
(event.keyCode === 65 && event.ctrlKey === true) ||
(event.keyCode === 67 && event.ctrlKey === true) ||
(event.keyCode === 86 && event.ctrlKey === true) ||
(event.keyCode === 88 && event.ctrlKey === true) ||
// Allow: home, end, left, right
(event.keyCode >= 35 && event.keyCode <= 39)) {
return;
}
// Ensure that it is a number and stop the keypress
if ((event.shiftKey || (event.keyCode < 48 || event.keyCode > 57)) && (event.keyCode < 96 || event.keyCode > 105)) {
event.preventDefault();
}
}
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
const paste = event.clipboardData?.getData('text') || '';
const sanitized = paste.replace(/[^\d]/g, '');
// If the paste content has non-numeric characters, prevent default and handle manually
if (sanitized !== paste) {
event.preventDefault();
const input = event.target as HTMLInputElement;
const currentValue = input.value;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const newValue = currentValue.substring(0, start) + sanitized + currentValue.substring(end);
// Update both the input value and form control
input.value = newValue;
this.ngControl.control?.setValue(newValue);
// Set cursor position after pasted content
setTimeout(() => {
input.setSelectionRange(start + sanitized.length, start + sanitized.length);
});
}
// If paste content is all numeric, allow normal paste behavior
// The input event will handle form control synchronization
}
}

View File

@@ -29,7 +29,7 @@ export interface BlocklistSettings {
blocklistType: BlocklistType;
}
export interface ContentBlockerConfig {
export interface MalwareBlockerConfig {
enabled: boolean;
cronExpression: string;
useAdvancedScheduling: boolean;

View File

@@ -68,7 +68,8 @@ Comprehensive download management and automation features for your *arr applicat
icon="🚫"
>
- Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
- Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
- Remove and block known malware based on patterns found by the community.
</ConfigSection>

View File

@@ -16,8 +16,8 @@ This is a detailed explanation of how the recurring cleanup jobs work.
<div className={styles.section}>
<ConfigSection
id="content-blocker"
title="1. Content Blocker"
id="malware-blocker"
title="1. Malware Blocker"
description="Automatically filters and removes unwanted content based on configurable blocklists"
icon="🚫"
>
@@ -47,7 +47,7 @@ This is a detailed explanation of how the recurring cleanup jobs work.
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**.
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
- Check each queue item if it meets one of the following condition in the download client:
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **Malware Blocker**).
- All associated files are marked as **unwanted/skipped/do not download**.
- If the item **DOES NOT** match the above criteria, it will be skipped.
- If the item **DOES** match the criteria or has received the **maximum number of strikes**:

View File

@@ -10,9 +10,9 @@ import {
styles
} from '@site/src/components/documentation';
# Content Blocker
# Malware Blocker
The Content Blocker automatically blocks or removes downloads from your download client based on configurable blocklists. This helps prevent unwanted content from being downloaded and manages content filtering across your *arr applications.
The Malware Blocker automatically blocks or removes downloads from your download client based on configurable blocklists. This helps prevent unwanted content from being downloaded and manages content filtering across your *arr applications.
<div className={styles.documentationPage}>
@@ -23,12 +23,12 @@ These settings need a download client to be configured.
<div className={styles.section}>
<ConfigSection
id="enable-content-blocker"
title="Enable Content Blocker"
id="enable-malware-blocker"
title="Enable Malware Blocker"
icon="🔄"
>
When enabled, the Content Blocker will run according to the configured schedule to automatically block or remove downloads based on the configured blocklists.
When enabled, the Malware Blocker will run according to the configured schedule to automatically block or remove downloads based on the configured blocklists.
</ConfigSection>
@@ -38,7 +38,7 @@ When enabled, the Content Blocker will run according to the configured schedule
icon="📅"
>
Choose how to configure the Content Blocker schedule:
Choose how to configure the Malware Blocker schedule:
- **Basic**: Simple interval-based scheduling (every X minutes/hours/seconds)
- **Advanced**: Full cron expression control for complex schedules
@@ -50,7 +50,7 @@ Choose how to configure the Content Blocker schedule:
icon="⏲️"
>
Enter a valid Quartz.NET cron expression to control when the Content Blocker runs.
Enter a valid Quartz.NET cron expression to control when the Malware Blocker runs.
**Common Cron Examples:**
- `0 0/5 * ? * * *` - Every 5 minutes
@@ -121,7 +121,7 @@ This feature permanently deletes downloads that match malware patterns. While th
icon="✅"
>
When enabled, the Content Blocker will use the configured blocklist to filter content.
When enabled, the Malware Blocker will use the configured blocklist to filter content.
</ConfigSection>

View File

@@ -15,8 +15,8 @@ For most users, we recommend the Docker installation method as it provides the m
## Table of Contents
- [Docker Installation (Recommended)](#docker-installation-recommended)
- [Docker Run Method](#docker-run-method)
- [Docker Compose Method](#docker-compose-method)
- [Docker Run Method](#docker-run)
- [Docker Compose Method](#docker-compose)
- [Windows Installation](#windows-installation)
- [Windows Installer](#windows-installer)
- [Windows Portable](#windows-portable)
@@ -25,7 +25,6 @@ For most users, we recommend the Docker installation method as it provides the m
- [macOS Portable](#macos-portable)
- [Linux Installation](#linux-installation)
- [Post Installation](#post-installation)
- [Troubleshooting](#troubleshooting)
---

View File

@@ -6,17 +6,17 @@ import {
styles
} from '@site/src/components/documentation';
# Using Cleanuparr's Content Blocker
# Using Cleanuparr's Malware Blocker
Configure Cleanuparr's Content Blocker feature to automatically filter downloads based on custom blocklists.
Configure Cleanuparr's Malware Blocker feature to automatically filter downloads based on custom blocklists.
<div className={styles.documentationPage}>
<div className={styles.section}>
<StepGuide>
<Step title="Enable Content Blocker">
Use **Cleanuparr** with `Content Blocker` enabled.
<Step title="Enable Malware Blocker">
Use **Cleanuparr** with `Malware Blocker` enabled.
</Step>
<Step title="Configure Blocklist">
@@ -27,7 +27,7 @@ Configure Cleanuparr's Content Blocker feature to automatically filter downloads
</Step>
<Step title="Automated Processing">
The **Content Blocker** will perform a cleanup process as described in the [How it works](/docs/how_it_works) section.
The **Malware Blocker** will perform a cleanup process as described in the [How it works](/docs/how_it_works) section.
</Step>
</StepGuide>

View File

@@ -112,8 +112,8 @@ function FeaturesSection() {
const features: FeatureCardProps[] = [
{
icon: "🚫",
title: "Content Blocking",
description: "Automatically block and remove malicious files using customizable blocklists and whitelists.",
title: "Malware Blocking",
description: "Automatically block and remove malicious files using customizable blocklists.",
color: "#dc3545"
},
{
@@ -143,7 +143,7 @@ function FeaturesSection() {
{
icon: "🔔",
title: "Smart Notifications",
description: "Get alerted about strikes, removals, and cleanup operations via Discord or Apprise.",
description: "Get alerted about strikes, removals, and cleanup operations via Notifiarr or Apprise.",
color: "#fd7e14"
}
];