mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 |
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -515,11 +515,11 @@ public class ConfigurationController : ControllerBase
|
||||
// Update the scheduler based on configuration changes
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
{
|
||||
@@ -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>
|
||||
{
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
{
|
||||
@@ -18,8 +18,8 @@ export const routes: Routes = [
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'content-blocker',
|
||||
loadComponent: () => import('./settings/content-blocker/content-blocker-settings.component').then(m => m.ContentBlockerSettingsComponent),
|
||||
path: 'malware-blocker',
|
||||
loadComponent: () => import('./settings/malware-blocker/malware-blocker-settings.component').then(m => m.MalwareBlockerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -108,7 +108,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Settings routes
|
||||
{ route: '/general-settings', navigationPath: ['settings', 'general'] },
|
||||
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
|
||||
{ route: '/content-blocker', navigationPath: ['settings', 'content-blocker'] },
|
||||
{ route: '/malware-blocker', navigationPath: ['settings', 'malware-blocker'] },
|
||||
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
|
||||
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
|
||||
|
||||
@@ -224,7 +224,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
children: [
|
||||
{ id: 'general', label: 'General', icon: 'pi pi-cog', route: '/general-settings' },
|
||||
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
|
||||
{ id: 'content-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/content-blocker' },
|
||||
{ id: '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' }
|
||||
]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
@@ -16,15 +16,15 @@
|
||||
<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">
|
||||
@@ -62,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>
|
||||
@@ -88,12 +88,12 @@
|
||||
</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-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
@@ -152,7 +152,7 @@
|
||||
<!-- 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) {
|
||||
@@ -222,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) {
|
||||
@@ -292,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) {
|
||||
@@ -362,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) {
|
||||
@@ -432,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) {
|
||||
@@ -510,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
|
||||
@@ -520,7 +520,7 @@
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-secondary p-button-outlined ml-2"
|
||||
(click)="resetContentBlockerConfig()"
|
||||
(click)="resetMalwareBlockerConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -53,16 +53,15 @@
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Channel ID
|
||||
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
placeholder="Enter channel ID"
|
||||
formControlName="channelId"
|
||||
[min]="0"
|
||||
[useGrouping]="false"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<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>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -30,6 +31,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
|
||||
ButtonModule,
|
||||
ToastModule,
|
||||
LoadingErrorStateComponent,
|
||||
NumericInputDirective,
|
||||
],
|
||||
providers: [NotificationConfigStore],
|
||||
templateUrl: "./notification-settings.component.html",
|
||||
|
||||
1
code/frontend/src/app/shared/directives/index.ts
Normal file
1
code/frontend/src/app/shared/directives/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './numeric-input.directive';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface BlocklistSettings {
|
||||
blocklistType: BlocklistType;
|
||||
}
|
||||
|
||||
export interface ContentBlockerConfig {
|
||||
export interface MalwareBlockerConfig {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
useAdvancedScheduling: boolean;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user