mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
15 Commits
v2.0.19
...
fix_qbit_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b834078c11 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 | ||
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac | ||
|
|
323bfc4d2e | ||
|
|
dca45585ca | ||
|
|
8b5918d221 | ||
|
|
9c227c1f59 |
14
.github/workflows/build-docker.yml
vendored
14
.github/workflows/build-docker.yml
vendored
@@ -29,6 +29,8 @@ jobs:
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
@@ -36,6 +38,12 @@ jobs:
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
@@ -58,6 +66,12 @@ jobs:
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
|
||||
36
.github/workflows/cloudflare-pages.yml
vendored
Normal file
36
.github/workflows/cloudflare-pages.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'Cloudflare/**'
|
||||
- 'blacklist'
|
||||
- 'blacklist_permissive'
|
||||
- 'whitelist'
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
cp blacklist Cloudflare/static/
|
||||
cp blacklist_permissive Cloudflare/static/
|
||||
cp whitelist Cloudflare/static/
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
3
Cloudflare/_headers
Normal file
3
Cloudflare/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
@@ -0,0 +1,2 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
@@ -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
|
||||
@@ -495,7 +495,7 @@ public class ConfigurationController : ControllerBase
|
||||
// Validate cron expression if present
|
||||
if (!string.IsNullOrEmpty(newConfig.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.ContentBlocker);
|
||||
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.MalwareBlocker);
|
||||
}
|
||||
|
||||
// Get existing config
|
||||
@@ -513,13 +513,13 @@ public class ConfigurationController : ControllerBase
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Update the scheduler based on configuration changes
|
||||
await UpdateJobSchedule(oldConfig, JobType.ContentBlocker);
|
||||
await UpdateJobSchedule(oldConfig, JobType.MalwareBlocker);
|
||||
|
||||
return Ok(new { Message = "ContentBlocker configuration updated successfully" });
|
||||
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save ContentBlocker configuration");
|
||||
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -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,7 +27,7 @@ public static class LoggingDI
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
||||
|
||||
// Determine job name padding
|
||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
|
||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
|
||||
int jobPadding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Determine instance name padding
|
||||
|
||||
@@ -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,
|
||||
@@ -66,27 +66,27 @@ public sealed class ContentBlocker : GenericHandler
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
|
||||
|
||||
if (config.Sonarr.Enabled)
|
||||
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
|
||||
}
|
||||
|
||||
if (config.Radarr.Enabled)
|
||||
if (config.Radarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
|
||||
}
|
||||
|
||||
if (config.Lidarr.Enabled)
|
||||
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
|
||||
}
|
||||
|
||||
if (config.Readarr.Enabled)
|
||||
if (config.Readarr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
|
||||
}
|
||||
|
||||
if (config.Whisparr.Enabled)
|
||||
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
|
||||
{
|
||||
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
|
||||
}
|
||||
@@ -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.3.1-alpha" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
|
||||
@@ -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);
|
||||
@@ -69,6 +69,7 @@ public partial class DelugeService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
@@ -80,7 +81,7 @@ public partial class DelugeService
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsDefinitelyMalware(name))
|
||||
if (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;
|
||||
@@ -100,16 +100,6 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
protected bool IsDefinitelyMalware(string filename)
|
||||
{
|
||||
if (filename.Contains("thepirateheaven.org", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
@@ -74,6 +74,7 @@ public partial class QBitService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
@@ -85,7 +86,7 @@ public partial class QBitService
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (IsDefinitelyMalware(file.Name))
|
||||
if (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);
|
||||
@@ -57,6 +57,7 @@ public partial class TransmissionService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
@@ -68,7 +69,7 @@ public partial class TransmissionService
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (IsDefinitelyMalware(download.Files[i].Name))
|
||||
if (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);
|
||||
@@ -62,10 +62,11 @@ public partial class UTorrentService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
for (int i = 0; i < files.Count; i++)
|
||||
{
|
||||
if (IsDefinitelyMalware(files[i].Name))
|
||||
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
|
||||
{
|
||||
@@ -23,8 +22,11 @@ public sealed class BlocklistProvider
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Dictionary<InstanceType, string> _configHashes = new();
|
||||
private static DateTime _lastLoadTime = DateTime.MinValue;
|
||||
private const int LoadIntervalHours = 4;
|
||||
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
|
||||
private const int DefaultLoadIntervalHours = 4;
|
||||
private const int FastLoadIntervalMinutes = 5;
|
||||
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
|
||||
private const string MalwareListKey = "MALWARE_PATTERNS";
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
@@ -46,78 +48,89 @@ 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();
|
||||
bool shouldReload = false;
|
||||
|
||||
if (_lastLoadTime.AddHours(LoadIntervalHours) < DateTime.UtcNow)
|
||||
{
|
||||
shouldReload = true;
|
||||
_lastLoadTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
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);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
|
||||
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);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
|
||||
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);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
|
||||
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 Lidarr blocklist if needed
|
||||
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
|
||||
// Check and update Readarr blocklist if needed
|
||||
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);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
|
||||
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++;
|
||||
}
|
||||
|
||||
// Always check and update malware patterns
|
||||
await LoadMalwarePatternsAsync();
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
|
||||
@@ -154,6 +167,77 @@ public sealed class BlocklistProvider
|
||||
|
||||
return regexes ?? [];
|
||||
}
|
||||
|
||||
public ConcurrentBag<string> GetMalwarePatterns()
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
|
||||
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
private TimeSpan GetLoadInterval(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (uri.Host.Equals("cleanuparr.pages.dev", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
}
|
||||
|
||||
return TimeSpan.FromHours(DefaultLoadIntervalHours);
|
||||
}
|
||||
|
||||
// If fast load interval for local files
|
||||
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
}
|
||||
|
||||
private bool ShouldReloadBlocklist(string identifier, TimeSpan interval)
|
||||
{
|
||||
if (!_lastLoadTimes.TryGetValue(identifier, out DateTime lastLoad))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateTime.UtcNow - lastLoad >= interval;
|
||||
}
|
||||
|
||||
private async Task LoadMalwarePatternsAsync()
|
||||
{
|
||||
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
|
||||
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Loading malware patterns");
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
ConcurrentBag<string> patterns = [];
|
||||
|
||||
Parallel.ForEach(filePatterns, options, pattern =>
|
||||
{
|
||||
patterns.Add(pattern);
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
|
||||
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -20,6 +20,16 @@ public class FilenameEvaluator : IFilenameEvaluator
|
||||
return IsValidAgainstPatterns(filename, type, patterns) && IsValidAgainstRegexes(filename, type, regexes);
|
||||
}
|
||||
|
||||
public bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns)
|
||||
{
|
||||
if (malwarePatterns.Count is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return malwarePatterns.Any(pattern => filename.Contains(pattern, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsValidAgainstPatterns(string filename, BlocklistType type, ConcurrentBag<string> patterns)
|
||||
{
|
||||
if (patterns.Count is 0)
|
||||
@@ -2,9 +2,11 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
|
||||
public interface IFilenameEvaluator
|
||||
{
|
||||
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
|
||||
|
||||
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ public static class CacheKeys
|
||||
public static string BlocklistPatterns(InstanceType instanceType) => $"{instanceType.ToString()}_patterns";
|
||||
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
|
||||
|
||||
public static string KnownMalwarePatterns() => "KNOWN_MALWARE_PATTERNS";
|
||||
|
||||
public static string StrikeItem(string hash, StrikeType strikeType) => $"item_{hash}_{strikeType.ToString()}";
|
||||
|
||||
public static string IgnoredDownloads(string name) => $"{name}_ignored";
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,6 @@ namespace Cleanuparr.Infrastructure.Models;
|
||||
public enum JobType
|
||||
{
|
||||
QueueCleaner,
|
||||
ContentBlocker,
|
||||
MalwareBlocker,
|
||||
DownloadCleaner
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public static class CronValidationHelper
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (jobType is not JobType.ContentBlocker && triggerValue < Constants.TriggerMinLimit)
|
||||
if (jobType is not JobType.MalwareBlocker && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
645
code/backend/Cleanuparr.Persistence/Migrations/Data/20250801143446_AddKnownMalwareOption.Designer.cs
generated
Normal file
645
code/backend/Cleanuparr.Persistence/Migrations/Data/20250801143446_AddKnownMalwareOption.Designer.cs
generated
Normal file
@@ -0,0 +1,645 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250801143446_AddKnownMalwareOption")]
|
||||
partial class AddKnownMalwareOption
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<short>("FailedImportMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_configs");
|
||||
|
||||
b.ToTable("arr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<Guid>("ArrConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_instances");
|
||||
|
||||
b.HasIndex("ArrConfigId")
|
||||
.HasDatabaseName("ix_arr_instances_arr_config_id");
|
||||
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<string>("LogLevel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_delete_private");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_ignore_private");
|
||||
|
||||
b1.PrimitiveCollection<string>("IgnoredPatterns")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failed_import_ignored_patterns");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_delete_private");
|
||||
|
||||
b1.Property<string>("IgnoreAboveSize")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_ignore_above_size");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_max_strikes");
|
||||
|
||||
b1.Property<double>("MaxTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("slow_max_time");
|
||||
|
||||
b1.Property<string>("MinSpeed")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_min_speed");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_delete_private");
|
||||
|
||||
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_downloading_metadata_max_strikes");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_max_strikes");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_queue_cleaner_configs");
|
||||
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("DownloadCleanerConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
|
||||
|
||||
b.Navigation("DownloadCleanerConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKnownMalwareOption : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "delete_known_malware",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "delete_known_malware",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -19,6 +19,8 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
public bool IgnorePrivate { get; set; }
|
||||
|
||||
public bool DeletePrivate { get; set; }
|
||||
|
||||
public bool DeleteKnownMalware { get; set; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; set; } = new();
|
||||
|
||||
@@ -6,11 +6,28 @@ export const routes: Routes = [
|
||||
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
|
||||
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
|
||||
{ path: 'events', loadComponent: () => import('./events/events-viewer/events-viewer.component').then(m => m.EventsViewerComponent) },
|
||||
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent),
|
||||
path: 'general-settings',
|
||||
loadComponent: () => import('./settings/general-settings/general-settings.component').then(m => m.GeneralSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'queue-cleaner',
|
||||
loadComponent: () => import('./settings/queue-cleaner/queue-cleaner-settings.component').then(m => m.QueueCleanerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'malware-blocker',
|
||||
loadComponent: () => import('./settings/malware-blocker/malware-blocker-settings.component').then(m => m.MalwareBlockerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'download-cleaner',
|
||||
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
|
||||
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
|
||||
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
|
||||
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },
|
||||
|
||||
@@ -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,14 +63,15 @@ 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',
|
||||
'jobSchedule.type': 'run-schedule',
|
||||
'ignorePrivate': 'ignore-private',
|
||||
'deletePrivate': 'delete-private',
|
||||
'deleteKnownMalware': 'delete-known-malware',
|
||||
'sonarr.enabled': 'enable-sonarr-blocklist',
|
||||
'sonarr.blocklistPath': 'sonarr-blocklist-path',
|
||||
'sonarr.blocklistType': 'sonarr-blocklist-type',
|
||||
|
||||
@@ -10,115 +10,105 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<nav class="nav-menu">
|
||||
<!-- Project Sponsors Link -->
|
||||
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
|
||||
<!-- Show loading skeleton while determining navigation state -->
|
||||
<nav class="nav-menu" *ngIf="!isNavigationReady">
|
||||
<div class="nav-skeleton">
|
||||
<div class="skeleton-item"
|
||||
*ngFor="let item of getSkeletonItems()"
|
||||
[class.sponsor-skeleton]="item.isSponsor">
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Show actual navigation when ready -->
|
||||
<nav class="nav-menu"
|
||||
*ngIf="isNavigationReady"
|
||||
[@staggerItems]>
|
||||
<!-- Project Sponsors Link (always visible) -->
|
||||
<a href="https://cleanuparr.github.io/Cleanuparr/support"
|
||||
class="nav-item sponsor-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper heart-icon">
|
||||
<i class="pi pi-heart"></i>
|
||||
</div>
|
||||
<span>Become A Sponsor</span>
|
||||
</a>
|
||||
|
||||
<a [routerLink]="['/dashboard']" class="nav-item" [class.active]="router.url.includes('/dashboard')" (click)="onNavItemClick()">
|
||||
<!-- Go Back button (shown when not at root level) -->
|
||||
<div class="nav-item go-back-button"
|
||||
*ngIf="canGoBack"
|
||||
(click)="goBack()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-home"></i>
|
||||
<i class="pi pi-arrow-left"></i>
|
||||
</div>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Settings Group -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Settings</div>
|
||||
<a [routerLink]="['/sonarr']" class="nav-item" [class.active]="router.url.includes('/sonarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-play-circle"></i>
|
||||
</div>
|
||||
<span>Sonarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/radarr']" class="nav-item" [class.active]="router.url.includes('/radarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-play-circle"></i>
|
||||
</div>
|
||||
<span>Radarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/lidarr']" class="nav-item" [class.active]="router.url.includes('/lidarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-bolt"></i>
|
||||
</div>
|
||||
<span>Lidarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-book"></i>
|
||||
</div>
|
||||
<span>Readarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/whisparr']" class="nav-item" [class.active]="router.url.includes('/whisparr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-lock"></i>
|
||||
</div>
|
||||
<span>Whisparr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-download"></i>
|
||||
</div>
|
||||
<span>Download Clients</span>
|
||||
</a>
|
||||
<a [routerLink]="['/settings']" class="nav-item" [class.active]="router.url.includes('/settings')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-trash"></i>
|
||||
</div>
|
||||
<span>Cleanup</span>
|
||||
</a>
|
||||
<a [routerLink]="['/notifications']" class="nav-item" [class.active]="router.url.includes('/notifications')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-bell"></i>
|
||||
</div>
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
<span>Go Back</span>
|
||||
</div>
|
||||
|
||||
<!-- Activity Group -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Activity</div>
|
||||
<ng-container *ngFor="let item of menuItems">
|
||||
<ng-container *ngIf="!['Dashboard', 'Settings'].includes(item.label)">
|
||||
<a [routerLink]="item.route" class="nav-item" [class.active]="router.url.includes(item.route)" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<!-- Breadcrumb (optional, for better UX) -->
|
||||
<div class="breadcrumb"
|
||||
*ngIf="navigationBreadcrumb.length > 0">
|
||||
<span *ngFor="let crumb of navigationBreadcrumb; let last = last; trackBy: trackByBreadcrumb">
|
||||
{{ crumb.label }}
|
||||
<i class="pi pi-chevron-right" *ngIf="!last"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation items container with container-level animation -->
|
||||
<div class="navigation-items-container"
|
||||
[@navigationContainer]="navigationStateKey">
|
||||
<!-- Current level navigation items -->
|
||||
<ng-container *ngFor="let item of currentNavigation; trackBy: trackByItemId">
|
||||
<!-- Section headers for top-level sections -->
|
||||
<div
|
||||
class="nav-section-header"
|
||||
*ngIf="item.isHeader">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Items with children (drill-down) - exclude top-level items -->
|
||||
<div
|
||||
class="nav-item nav-parent"
|
||||
*ngIf="item.children && item.children.length > 0 && !item.topLevel"
|
||||
(click)="navigateToLevel(item)">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="nav-chevron">
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct navigation items -->
|
||||
<a
|
||||
[routerLink]="item.route"
|
||||
class="nav-item"
|
||||
*ngIf="!item.children && item.route && !item.isHeader"
|
||||
[class.active]="router.url.includes(item.route)"
|
||||
(click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="nav-badge" *ngIf="item.badge">{{ item.badge }}</span>
|
||||
</a>
|
||||
|
||||
<!-- External links -->
|
||||
<a
|
||||
[href]="item.href"
|
||||
class="nav-item"
|
||||
*ngIf="!item.children && item.isExternal && !item.isHeader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Resources Group -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Resources</div>
|
||||
<a href="https://github.com/Cleanuparr/Cleanuparr/issues" class="nav-item" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-github"></i>
|
||||
</div>
|
||||
<span>Issues and requests</span>
|
||||
</a>
|
||||
|
||||
<a href="https://discord.gg/SCtMCgtsc4" class="nav-item" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-discord"></i>
|
||||
</div>
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Recommended apps</div>
|
||||
<a href="https://github.com/plexguide/Huntarr.io" class="nav-item" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-github"></i>
|
||||
</div>
|
||||
<span>Huntarr</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
// Main container stability
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden; // Prevent scrolling
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Logo container
|
||||
.logo-container {
|
||||
display: flex;
|
||||
@@ -50,7 +58,69 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
gap: 0; // Remove gap to prevent layout shifts
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
// Prevent horizontal scrolling
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
// Fixed minimum height to prevent jumping
|
||||
min-height: 400px;
|
||||
|
||||
// Navigation items container for smooth animations
|
||||
.navigation-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px; // Consistent spacing between navigation items
|
||||
position: relative; // Ensure proper stacking context for animations
|
||||
width: 100%; // Take full width of parent
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
.nav-skeleton {
|
||||
padding: 0;
|
||||
|
||||
.skeleton-item {
|
||||
height: 60px; // Match actual nav-item height
|
||||
padding: 10px 20px; // Match nav-item padding
|
||||
margin-bottom: 8px; // Match nav-item spacing
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--surface-200) 25%, var(--surface-300) 50%, var(--surface-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.sponsor-skeleton {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
// Add fake icon and text areas to match real content
|
||||
&::before {
|
||||
content: '';
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-300);
|
||||
margin-right: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
height: 20px;
|
||||
background: var(--surface-300);
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sponsor link
|
||||
.sponsor-link {
|
||||
@@ -67,20 +137,78 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nav groups
|
||||
.nav-group {
|
||||
|
||||
// Go back button styling
|
||||
.go-back-button {
|
||||
background-color: var(--surface-200);
|
||||
border: 1px solid var(--surface-300);
|
||||
margin-bottom: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.nav-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
padding: 0 20px 8px;
|
||||
margin: 5px 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
&:hover {
|
||||
transform: translateX(2px);
|
||||
background-color: var(--surface-300);
|
||||
|
||||
.nav-icon-wrapper i {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb styling
|
||||
.breadcrumb {
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
span {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
i {
|
||||
margin: 0 8px;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// Section headers for top-level sections
|
||||
.nav-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 20px 4px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 15px 0 8px 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
|
||||
.nav-icon-wrapper {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-200);
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +222,12 @@
|
||||
border-radius: 0 6px 6px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
margin-bottom: 8px; // Consistent spacing instead of gap
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-icon-wrapper {
|
||||
width: 40px;
|
||||
@@ -106,17 +239,33 @@
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
flex-shrink: 0; // Prevent icon from shrinking
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1; // Take available space
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -131,7 +280,9 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(4px);
|
||||
background-color: var(--surface-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.nav-icon-wrapper {
|
||||
background-color: rgba(var(--primary-500-rgb), 0.1);
|
||||
@@ -161,6 +312,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parent navigation items (with children)
|
||||
.nav-parent {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
.nav-chevron {
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .nav-chevron i {
|
||||
transform: translateX(3px) scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading skeleton animation
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Component, Input, inject, Output, EventEmitter } from '@angular/core';
|
||||
import { Component, Input, inject, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
@@ -10,6 +13,24 @@ interface MenuItem {
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
route?: string; // For direct navigation items
|
||||
children?: NavigationItem[]; // For parent items with sub-menus
|
||||
isExternal?: boolean; // For external links
|
||||
href?: string; // For external URLs
|
||||
badge?: string; // For notification badges
|
||||
topLevel?: boolean; // If true, shows children directly on top level instead of drill-down
|
||||
isHeader?: boolean; // If true, renders as a section header (non-clickable)
|
||||
}
|
||||
|
||||
interface RouteMapping {
|
||||
route: string;
|
||||
navigationPath: string[]; // Array of navigation item IDs leading to this route
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar-content',
|
||||
standalone: true,
|
||||
@@ -19,9 +40,37 @@ interface MenuItem {
|
||||
ButtonModule
|
||||
],
|
||||
templateUrl: './sidebar-content.component.html',
|
||||
styleUrl: './sidebar-content.component.scss'
|
||||
styleUrl: './sidebar-content.component.scss',
|
||||
animations: [
|
||||
trigger('staggerItems', [
|
||||
transition(':enter', [
|
||||
query(':enter', [
|
||||
style({ transform: 'translateX(30px)', opacity: 0 }),
|
||||
stagger('50ms', [
|
||||
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)', style({ transform: 'translateX(0)', opacity: 1 }))
|
||||
])
|
||||
], { optional: true })
|
||||
])
|
||||
]),
|
||||
// Container-level navigation animation (replaces individual item animations)
|
||||
trigger('navigationContainer', [
|
||||
transition('* => *', [
|
||||
style({ transform: 'translateX(100%)', opacity: 0 }),
|
||||
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)',
|
||||
style({ transform: 'translateX(0)', opacity: 1 })
|
||||
)
|
||||
])
|
||||
]),
|
||||
// Simple fade in animation for initial load
|
||||
trigger('fadeIn', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0 }),
|
||||
animate('200ms ease-out', style({ opacity: 1 }))
|
||||
])
|
||||
])
|
||||
]
|
||||
})
|
||||
export class SidebarContentComponent {
|
||||
export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() menuItems: MenuItem[] = [];
|
||||
@Input() isMobile = false;
|
||||
@Output() navItemClicked = new EventEmitter<void>();
|
||||
@@ -29,6 +78,404 @@ export class SidebarContentComponent {
|
||||
// Inject router for active route styling
|
||||
public router = inject(Router);
|
||||
|
||||
// New properties for drill-down navigation
|
||||
navigationData: NavigationItem[] = [];
|
||||
currentNavigation: NavigationItem[] = [];
|
||||
navigationBreadcrumb: NavigationItem[] = [];
|
||||
canGoBack = false;
|
||||
|
||||
// Pre-rendering optimization properties
|
||||
isNavigationReady = false;
|
||||
private hasInitialized = false;
|
||||
|
||||
// Animation trigger property - changes to force re-render and trigger animations
|
||||
navigationStateKey = 0;
|
||||
|
||||
// Route synchronization properties
|
||||
private routerSubscription?: Subscription;
|
||||
private routeMappings: RouteMapping[] = [
|
||||
// Dashboard
|
||||
{ route: '/dashboard', navigationPath: ['dashboard'] },
|
||||
|
||||
// Media Management routes
|
||||
{ route: '/sonarr', navigationPath: ['media-apps', 'sonarr'] },
|
||||
{ route: '/radarr', navigationPath: ['media-apps', 'radarr'] },
|
||||
{ route: '/lidarr', navigationPath: ['media-apps', 'lidarr'] },
|
||||
{ route: '/readarr', navigationPath: ['media-apps', 'readarr'] },
|
||||
{ route: '/whisparr', navigationPath: ['media-apps', 'whisparr'] },
|
||||
{ route: '/download-clients', navigationPath: ['media-apps', 'download-clients'] },
|
||||
|
||||
// Settings routes
|
||||
{ route: '/general-settings', navigationPath: ['settings', 'general'] },
|
||||
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
|
||||
{ route: '/malware-blocker', navigationPath: ['settings', 'malware-blocker'] },
|
||||
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
|
||||
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
|
||||
|
||||
// Other routes will be handled dynamically
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
// Start with loading state
|
||||
this.isNavigationReady = false;
|
||||
|
||||
// Initialize navigation after showing skeleton
|
||||
setTimeout(() => {
|
||||
this.initializeNavigation();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['menuItems']) {
|
||||
this.updateActivityItems();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routerSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize navigation and determine correct level based on route
|
||||
*/
|
||||
private initializeNavigation(): void {
|
||||
if (this.hasInitialized) return;
|
||||
|
||||
// 1. Initialize navigation data
|
||||
this.setupNavigationData();
|
||||
|
||||
// 2. Update activity items if available
|
||||
if (this.menuItems && this.menuItems.length > 0) {
|
||||
this.updateActivityItems();
|
||||
}
|
||||
|
||||
// 3. Determine correct navigation level based on current route
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
|
||||
// 4. Mark as ready and subscribe to route changes
|
||||
this.isNavigationReady = true;
|
||||
this.hasInitialized = true;
|
||||
this.subscribeToRouteChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup basic navigation data structure
|
||||
*/
|
||||
private setupNavigationData(): void {
|
||||
this.navigationData = this.getNavigationData();
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build top-level navigation including expanded sections marked with topLevel: true
|
||||
*/
|
||||
private buildTopLevelNavigation(): NavigationItem[] {
|
||||
const topLevelItems: NavigationItem[] = [];
|
||||
|
||||
for (const item of this.navigationData) {
|
||||
if (item.topLevel && item.children) {
|
||||
// Add section header
|
||||
topLevelItems.push({
|
||||
id: `${item.id}-header`,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
isHeader: true
|
||||
});
|
||||
|
||||
// Add all children directly to top level
|
||||
topLevelItems.push(...item.children);
|
||||
} else {
|
||||
// Add item normally (drill-down behavior)
|
||||
topLevelItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return topLevelItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the navigation data structure
|
||||
*/
|
||||
private getNavigationData(): NavigationItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-home',
|
||||
route: '/dashboard'
|
||||
},
|
||||
{
|
||||
id: 'media-apps',
|
||||
label: 'Media Apps',
|
||||
icon: 'pi pi-play-circle',
|
||||
children: [
|
||||
{ id: 'sonarr', label: 'Sonarr', icon: 'pi pi-play-circle', route: '/sonarr' },
|
||||
{ id: 'radarr', label: 'Radarr', icon: 'pi pi-play-circle', route: '/radarr' },
|
||||
{ id: 'lidarr', label: 'Lidarr', icon: 'pi pi-bolt', route: '/lidarr' },
|
||||
{ id: 'readarr', label: 'Readarr', icon: 'pi pi-book', route: '/readarr' },
|
||||
{ id: 'whisparr', label: 'Whisparr', icon: 'pi pi-lock', route: '/whisparr' },
|
||||
{ id: 'download-clients', label: 'Download Clients', icon: 'pi pi-download', route: '/download-clients' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-cog',
|
||||
children: [
|
||||
{ id: 'general', label: 'General', icon: 'pi pi-cog', route: '/general-settings' },
|
||||
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
|
||||
{ id: 'malware-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/malware-blocker' },
|
||||
{ id: 'download-cleaner', label: 'Download Cleaner', icon: 'pi pi-trash', route: '/download-cleaner' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
icon: 'pi pi-chart-line',
|
||||
children: [] // Will be populated dynamically from menuItems
|
||||
},
|
||||
{
|
||||
id: 'help-support',
|
||||
label: 'Help & Support',
|
||||
icon: 'pi pi-question-circle',
|
||||
children: [
|
||||
{
|
||||
id: 'issues',
|
||||
label: 'Issues and Requests',
|
||||
icon: 'pi pi-github',
|
||||
isExternal: true,
|
||||
href: 'https://github.com/Cleanuparr/Cleanuparr/issues'
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
label: 'Discord',
|
||||
icon: 'pi pi-discord',
|
||||
isExternal: true,
|
||||
href: 'https://discord.gg/SCtMCgtsc4'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'suggested-apps',
|
||||
label: 'Suggested Apps',
|
||||
topLevel: true,
|
||||
icon: 'pi pi-star',
|
||||
children: [
|
||||
{
|
||||
id: 'huntarr',
|
||||
label: 'Huntarr',
|
||||
icon: 'pi pi-github',
|
||||
isExternal: true,
|
||||
href: 'https://github.com/plexguide/Huntarr.io'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to route mapping synchronously without delays
|
||||
*/
|
||||
private navigateToRouteMappingSync(mapping: RouteMapping): void {
|
||||
// No delays, no async operations - just set the state
|
||||
this.navigationBreadcrumb = [];
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
|
||||
for (let i = 0; i < mapping.navigationPath.length - 1; i++) {
|
||||
const itemId = mapping.navigationPath[i];
|
||||
// Find in original navigation data, not the flattened version
|
||||
const item = this.navigationData.find(nav => nav.id === itemId);
|
||||
|
||||
if (item && item.children && !item.topLevel) {
|
||||
// Only drill down if it's not a top-level section
|
||||
this.navigationBreadcrumb.push(item);
|
||||
this.currentNavigation = [...item.children];
|
||||
}
|
||||
}
|
||||
|
||||
this.updateNavigationState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skeleton items based on predicted navigation state
|
||||
*/
|
||||
getSkeletonItems(): Array<{isSponsor: boolean}> {
|
||||
const currentRoute = this.router.url;
|
||||
const mapping = this.findRouteMapping(currentRoute);
|
||||
|
||||
if (mapping && mapping.navigationPath.length > 1) {
|
||||
// We'll show sub-navigation, predict item count
|
||||
return [
|
||||
{ isSponsor: true },
|
||||
{ isSponsor: false }, // Go back
|
||||
...Array(6).fill({ isSponsor: false }) // Estimated items
|
||||
];
|
||||
}
|
||||
|
||||
// Default main navigation count
|
||||
return [
|
||||
{ isSponsor: true },
|
||||
...Array(5).fill({ isSponsor: false })
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackBy function for better performance
|
||||
*/
|
||||
trackByItemId(index: number, item: NavigationItem): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackBy function that includes navigation state for animation triggers
|
||||
*/
|
||||
trackByItemIdWithState(index: number, item: NavigationItem): string {
|
||||
return `${item.id}-${this.navigationStateKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackBy function for breadcrumb items
|
||||
*/
|
||||
trackByBreadcrumb(index: number, item: NavigationItem): string {
|
||||
return `${item.id}-${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update activity items from menuItems input
|
||||
*/
|
||||
private updateActivityItems(): void {
|
||||
const activityItem = this.navigationData.find(item => item.id === 'activity');
|
||||
if (activityItem && this.menuItems) {
|
||||
activityItem.children = this.menuItems
|
||||
.filter(item => !['Dashboard', 'Settings'].includes(item.label))
|
||||
.map(item => ({
|
||||
id: item.label.toLowerCase().replace(/\s+/g, '-'),
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
route: item.route,
|
||||
badge: item.badge
|
||||
}));
|
||||
|
||||
// Update route mappings for activity items
|
||||
this.updateActivityRouteMappings();
|
||||
|
||||
// Update current navigation if we're showing the root level
|
||||
if (this.navigationBreadcrumb.length === 0) {
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
}
|
||||
|
||||
// Re-sync with current route to handle activity routes
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route mappings for activity items
|
||||
*/
|
||||
private updateActivityRouteMappings(): void {
|
||||
// Remove old activity mappings
|
||||
this.routeMappings = this.routeMappings.filter(mapping =>
|
||||
!mapping.navigationPath[0] || !mapping.navigationPath[0].startsWith('activity')
|
||||
);
|
||||
|
||||
// Add new activity mappings
|
||||
const activityItem = this.navigationData.find(item => item.id === 'activity');
|
||||
if (activityItem?.children) {
|
||||
activityItem.children.forEach(child => {
|
||||
if (child.route) {
|
||||
this.routeMappings.push({
|
||||
route: child.route,
|
||||
navigationPath: ['activity', child.id]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync sidebar state with current route
|
||||
*/
|
||||
private syncSidebarWithCurrentRoute(): void {
|
||||
const currentRoute = this.router.url;
|
||||
const mapping = this.findRouteMapping(currentRoute);
|
||||
|
||||
if (mapping) {
|
||||
this.navigateToRouteMapping(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find route mapping for current route
|
||||
*/
|
||||
private findRouteMapping(route: string): RouteMapping | null {
|
||||
// Find exact match first, or routes that start with the mapping route
|
||||
const mapping = this.routeMappings.find(m =>
|
||||
route === m.route || route.startsWith(m.route + '/')
|
||||
);
|
||||
|
||||
return mapping || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate sidebar to match route mapping (used by route sync)
|
||||
*/
|
||||
private navigateToRouteMapping(mapping: RouteMapping): void {
|
||||
// Use the synchronous version
|
||||
this.navigateToRouteMappingSync(mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to route changes for real-time synchronization
|
||||
*/
|
||||
private subscribeToRouteChanges(): void {
|
||||
this.routerSubscription = this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a sub-level with animation trigger
|
||||
*/
|
||||
navigateToLevel(item: NavigationItem): void {
|
||||
if (item.children && item.children.length > 0) {
|
||||
this.navigationBreadcrumb.push(item);
|
||||
this.currentNavigation = item.children ? [...item.children] : [];
|
||||
this.navigationStateKey++; // Force animation trigger
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the previous level with animation trigger
|
||||
*/
|
||||
goBack(): void {
|
||||
if (this.navigationBreadcrumb.length > 0) {
|
||||
this.navigationBreadcrumb.pop();
|
||||
|
||||
if (this.navigationBreadcrumb.length === 0) {
|
||||
// Back to root level - use top-level navigation
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
} else {
|
||||
// Back to parent level
|
||||
const parent = this.navigationBreadcrumb[this.navigationBreadcrumb.length - 1];
|
||||
this.currentNavigation = parent.children ? [...parent.children] : [];
|
||||
}
|
||||
|
||||
this.navigationStateKey++; // Force animation trigger
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update navigation state
|
||||
*/
|
||||
private updateNavigationState(): void {
|
||||
this.canGoBack = this.navigationBreadcrumb.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation item click
|
||||
*/
|
||||
|
||||
@@ -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,3 +0,0 @@
|
||||
/* Content Blocker Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Download Cleaner</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic download cleanup</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic download cleanup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,7 +28,7 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Download Cleaner
|
||||
@@ -39,7 +42,7 @@
|
||||
<!-- Scheduling Mode Toggle -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('useAdvancedScheduling')"
|
||||
title="Click for documentation"></i>
|
||||
Scheduling Mode
|
||||
@@ -92,7 +95,7 @@
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="downloadCleanerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
@@ -124,7 +127,7 @@
|
||||
<!-- Delete Private Option -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private Torrents
|
||||
@@ -155,7 +158,7 @@
|
||||
<div class="category-title">
|
||||
<i class="pi pi-tag category-icon"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('name')"
|
||||
title="Click for documentation"></i>
|
||||
</div>
|
||||
@@ -167,7 +170,7 @@
|
||||
<div class="category-content">
|
||||
<div class="category-field">
|
||||
<label>
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('maxRatio')"
|
||||
title="Click for documentation"></i>
|
||||
Max Ratio
|
||||
@@ -185,7 +188,7 @@
|
||||
|
||||
<div class="category-field">
|
||||
<label>
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('minSeedTime')"
|
||||
title="Click for documentation"></i>
|
||||
Min Seed Time (hours)
|
||||
@@ -202,7 +205,7 @@
|
||||
|
||||
<div class="category-field">
|
||||
<label>
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('maxSeedTime')"
|
||||
title="Click for documentation"></i>
|
||||
Max Seed Time (hours)
|
||||
@@ -249,7 +252,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedEnabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Unlinked Download Handling
|
||||
@@ -263,7 +266,7 @@
|
||||
<!-- Unlinked Target Category -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedTargetCategory')"
|
||||
title="Click for documentation"></i>
|
||||
Target Category
|
||||
@@ -274,13 +277,14 @@
|
||||
</div>
|
||||
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="p-error">Target category is required</small>
|
||||
<small class="form-helper-text">Category to move unlinked downloads to</small>
|
||||
<small class="form-helper-text">You have to create a seeding rule for this category if you want to remove the downloads</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Use Tag Option -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedUseTag')"
|
||||
title="Click for documentation"></i>
|
||||
Use Tag
|
||||
@@ -294,7 +298,7 @@
|
||||
<!-- Ignored Root Directory -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedIgnoredRootDir')"
|
||||
title="Click for documentation"></i>
|
||||
Ignored Root Directory
|
||||
@@ -310,7 +314,7 @@
|
||||
<!-- Unlinked Categories -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedCategories')"
|
||||
title="Click for documentation"></i>
|
||||
Unlinked Categories
|
||||
@@ -370,8 +374,9 @@
|
||||
</p-card>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Download Cleaner Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<form [formGroup]="clientForm" class="p-fluid instance-form">
|
||||
<div class="field flex flex-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Enabled
|
||||
@@ -128,7 +128,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="client-name">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('name')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Name *
|
||||
@@ -146,7 +146,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="client-type">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('typeName')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Client Type *
|
||||
@@ -167,7 +167,7 @@
|
||||
<ng-container>
|
||||
<div class="field">
|
||||
<label for="client-host">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('host')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Host *
|
||||
@@ -187,7 +187,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="client-urlbase">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('urlBase')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
URL Base
|
||||
@@ -204,7 +204,7 @@
|
||||
|
||||
<div class="field" *ngIf="shouldShowUsernameField()">
|
||||
<label for="client-username">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('username')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Username
|
||||
@@ -221,7 +221,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="client-password">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('password')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Password
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>General Settings</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">General Configuration</h2>
|
||||
<span class="card-subtitle">Configure general application settings</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">General Configuration</h2>
|
||||
<span class="card-subtitle">Configure general application settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,7 +28,7 @@
|
||||
<!-- Display Support Banner -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('displaySupportBanner')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -40,7 +43,7 @@
|
||||
<!-- Dry Run -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('dryRun')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -55,7 +58,7 @@
|
||||
<!-- HTTP Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('httpMaxRetries')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -79,7 +82,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('httpTimeout')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -103,7 +106,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('httpCertificateValidation')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -124,7 +127,7 @@
|
||||
<!-- Search Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('searchEnabled')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -138,7 +141,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('searchDelay')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -163,7 +166,7 @@
|
||||
<!-- Log Level -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('logLevel')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -184,7 +187,7 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -241,3 +244,4 @@
|
||||
[style]="{ width: '500px', maxWidth: '90vw' }"
|
||||
[baseZIndex]="10000">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* General Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -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, {
|
||||
@@ -1,45 +1,48 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Malware Blocker</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Content Blocker Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Malware Blocker Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Loading/Error State Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="contentBlockerLoading() || contentBlockerLoadError()"
|
||||
[loading]="contentBlockerLoading()"
|
||||
[error]="contentBlockerLoadError()"
|
||||
*ngIf="malwareBlockerLoading() || malwareBlockerLoadError()"
|
||||
[loading]="malwareBlockerLoading()"
|
||||
[error]="malwareBlockerLoadError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Form Content - only shown when not loading and no error -->
|
||||
<form *ngIf="!contentBlockerLoading() && !contentBlockerLoadError()" [formGroup]="contentBlockerForm" class="p-fluid">
|
||||
<form *ngIf="!malwareBlockerLoading() && !malwareBlockerLoadError()" [formGroup]="malwareBlockerForm" class="p-fluid">
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Content Blocker
|
||||
Enable Malware Blocker
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, the content blocker will run according to the schedule</small>
|
||||
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduling Mode Toggle -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('useAdvancedScheduling')"
|
||||
title="Click for documentation"></i>
|
||||
Scheduling Mode
|
||||
@@ -59,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Basic Schedule Controls - shown when useAdvancedScheduling is false -->
|
||||
<div class="field-row" formGroupName="jobSchedule" *ngIf="!contentBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<div class="field-row" formGroupName="jobSchedule" *ngIf="!malwareBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
@@ -85,14 +88,14 @@
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small class="form-helper-text">How often the content blocker should run</small>
|
||||
<small class="form-helper-text">How often the Malware Blocker should run</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<div class="field-row" *ngIf="malwareBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
@@ -109,34 +112,47 @@
|
||||
<!-- Content Blocker Specific Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be processed</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('deleteKnownMalware')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Known Malware
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deleteKnownMalware" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, downloads matching known malware patterns will be deleted</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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) {
|
||||
@@ -151,7 +167,7 @@
|
||||
<div formGroupName="sonarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('sonarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Sonarr Blocklist
|
||||
@@ -164,7 +180,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('sonarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -180,7 +196,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('sonarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -206,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) {
|
||||
@@ -221,7 +237,7 @@
|
||||
<div formGroupName="radarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('radarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Radarr Blocklist
|
||||
@@ -234,7 +250,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('radarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -250,7 +266,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('radarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -276,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) {
|
||||
@@ -291,7 +307,7 @@
|
||||
<div formGroupName="lidarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('lidarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Lidarr Blocklist
|
||||
@@ -304,7 +320,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('lidarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -320,7 +336,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('lidarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -346,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) {
|
||||
@@ -361,7 +377,7 @@
|
||||
<div formGroupName="readarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Readarr Blocklist
|
||||
@@ -374,7 +390,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -390,7 +406,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -416,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) {
|
||||
@@ -431,7 +447,7 @@
|
||||
<div formGroupName="whisparr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Whisparr Blocklist
|
||||
@@ -444,7 +460,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -460,7 +476,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -494,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
|
||||
@@ -504,9 +520,10 @@
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-secondary p-button-outlined ml-2"
|
||||
(click)="resetContentBlockerConfig()"
|
||||
(click)="resetMalwareBlockerConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</p-card>
|
||||
</p-card>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Content Blocker Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -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]],
|
||||
@@ -132,6 +132,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
|
||||
ignorePrivate: [{ value: false, disabled: true }],
|
||||
deletePrivate: [{ value: false, disabled: true }],
|
||||
deleteKnownMalware: [{ value: false, disabled: true }],
|
||||
|
||||
// Blocklist settings for each Arr
|
||||
sonarr: this.formBuilder.group({
|
||||
@@ -163,40 +164,50 @@ 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) {
|
||||
// Reset form with the config values
|
||||
this.contentBlockerForm.patchValue({
|
||||
enabled: config.enabled,
|
||||
useAdvancedScheduling: config.useAdvancedScheduling || false,
|
||||
cronExpression: config.cronExpression,
|
||||
jobSchedule: config.jobSchedule || {
|
||||
// Handle the case where ignorePrivate is true but deletePrivate is also true
|
||||
// This shouldn't happen, but if it does, correct it
|
||||
const correctedConfig = { ...config };
|
||||
|
||||
// For Content Blocker
|
||||
if (correctedConfig.ignorePrivate && correctedConfig.deletePrivate) {
|
||||
correctedConfig.deletePrivate = false;
|
||||
}
|
||||
|
||||
// Reset form with the corrected config values
|
||||
this.malwareBlockerForm.patchValue({
|
||||
enabled: correctedConfig.enabled,
|
||||
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
|
||||
cronExpression: correctedConfig.cronExpression,
|
||||
jobSchedule: correctedConfig.jobSchedule || {
|
||||
every: 5,
|
||||
type: ScheduleUnit.Seconds
|
||||
},
|
||||
ignorePrivate: config.ignorePrivate,
|
||||
deletePrivate: config.deletePrivate,
|
||||
sonarr: config.sonarr,
|
||||
radarr: config.radarr,
|
||||
lidarr: config.lidarr,
|
||||
readarr: config.readarr,
|
||||
whisparr: config.whisparr,
|
||||
ignorePrivate: correctedConfig.ignorePrivate,
|
||||
deletePrivate: correctedConfig.deletePrivate,
|
||||
deleteKnownMalware: correctedConfig.deleteKnownMalware,
|
||||
sonarr: correctedConfig.sonarr,
|
||||
radarr: correctedConfig.radarr,
|
||||
lidarr: correctedConfig.lidarr,
|
||||
readarr: correctedConfig.readarr,
|
||||
whisparr: correctedConfig.whisparr,
|
||||
});
|
||||
|
||||
// Update all form control states
|
||||
this.updateFormControlDisabledStates(config);
|
||||
this.updateFormControlDisabledStates(correctedConfig);
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
|
||||
// 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);
|
||||
@@ -205,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
|
||||
@@ -238,23 +249,44 @@ 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) => {
|
||||
this.updateMainControlsState(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
// Add listener for ignorePrivate changes
|
||||
const ignorePrivateControl = this.malwareBlockerForm.get('ignorePrivate');
|
||||
if (ignorePrivateControl) {
|
||||
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((ignorePrivate: boolean) => {
|
||||
const deletePrivateControl = this.malwareBlockerForm.get('deletePrivate');
|
||||
|
||||
if (ignorePrivate && deletePrivateControl) {
|
||||
// If ignoring private, uncheck and disable delete private
|
||||
deletePrivateControl.setValue(false);
|
||||
deletePrivateControl.disable({ onlySelf: true });
|
||||
} else if (!ignorePrivate && deletePrivateControl) {
|
||||
// If not ignoring private, enable delete private (if main feature is enabled)
|
||||
const mainEnabled = this.malwareBlockerForm.get('enabled')?.value || false;
|
||||
if (mainEnabled) {
|
||||
deletePrivateControl.enable({ onlySelf: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -272,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)) {
|
||||
@@ -291,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$))
|
||||
@@ -302,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();
|
||||
});
|
||||
@@ -313,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;
|
||||
}
|
||||
|
||||
@@ -321,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);
|
||||
}
|
||||
|
||||
@@ -351,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);
|
||||
|
||||
@@ -369,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) {
|
||||
@@ -391,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');
|
||||
|
||||
@@ -410,26 +442,36 @@ 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("deletePrivate")?.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.malwareBlockerForm.get("ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.malwareBlockerForm.get("deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable({ onlySelf: true });
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable({ onlySelf: true });
|
||||
}
|
||||
|
||||
// Enable blocklist settings for each Arr
|
||||
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
|
||||
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);
|
||||
@@ -443,29 +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.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 = [];
|
||||
@@ -475,25 +518,26 @@ 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,
|
||||
deleteKnownMalware: formValue.deleteKnownMalware || false,
|
||||
sonarr: formValue.sonarr || {
|
||||
enabled: false,
|
||||
blocklistPath: "",
|
||||
@@ -522,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
|
||||
@@ -561,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 * * * * ?",
|
||||
@@ -572,6 +616,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
},
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
deleteKnownMalware: false,
|
||||
sonarr: {
|
||||
enabled: false,
|
||||
blocklistPath: "",
|
||||
@@ -608,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,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;
|
||||
}
|
||||
|
||||
@@ -636,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) {
|
||||
@@ -651,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;
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- API Key -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('notifiarr.apiKey')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -41,30 +41,35 @@
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
|
||||
<small class="form-helper-text">Your Notifiarr API key for authentication</small>
|
||||
<small class="form-helper-text">Passthrough integration must be enabled and a Passthrough key needs to be created in your profile</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel ID -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('notifiarr.channelId')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Channel ID
|
||||
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="channelId" inputId="notifiarrChannelId" placeholder="Enter channel ID" />
|
||||
<small class="form-helper-text">The channel ID where notifications will be sent</small>
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="channelId"
|
||||
placeholder="Enter Discord channel ID"
|
||||
numericInput
|
||||
/>
|
||||
<small class="form-helper-text">The Discord channel ID where notifications will be sent</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Triggers -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('eventTriggers')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -110,7 +115,7 @@
|
||||
<!-- URL -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.fullUrl')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -125,7 +130,7 @@
|
||||
<!-- Key -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.key')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -140,7 +145,7 @@
|
||||
<!-- Tags -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.tags')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -155,7 +160,7 @@
|
||||
<!-- Event Triggers -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('eventTriggers')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Subject, takeUntil } from "rxjs";
|
||||
import { NotificationConfigStore } from "./notification-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
import { NotificationsConfig } from "../../shared/models/notifications-config.model";
|
||||
import { NumericInputDirective } from "../../shared/directives";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
import { InputTextModule } from "primeng/inputtext";
|
||||
import { InputNumberModule } from "primeng/inputnumber";
|
||||
import { CheckboxModule } from "primeng/checkbox";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
@@ -24,10 +26,12 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
|
||||
ReactiveFormsModule,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
InputNumberModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
ToastModule,
|
||||
LoadingErrorStateComponent,
|
||||
NumericInputDirective,
|
||||
],
|
||||
providers: [NotificationConfigStore],
|
||||
templateUrl: "./notification-settings.component.html",
|
||||
@@ -221,7 +225,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
const formValues = this.notificationForm.value;
|
||||
|
||||
const config: NotificationsConfig = {
|
||||
notifiarr: formValues.notifiarr,
|
||||
notifiarr: {
|
||||
...formValues.notifiarr,
|
||||
channelId: formValues.notifiarr.channelId ? formValues.notifiarr.channelId.toString() : null,
|
||||
},
|
||||
apprise: formValues.apprise,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Queue Cleaner</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,7 +28,7 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Queue Cleaner
|
||||
@@ -39,7 +42,7 @@
|
||||
<!-- Scheduling Mode Toggle -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('useAdvancedScheduling')"
|
||||
title="Click for documentation"></i>
|
||||
Scheduling Mode
|
||||
@@ -92,7 +95,7 @@
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="queueCleanerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
@@ -123,7 +126,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.maxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
@@ -148,33 +151,33 @@
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be checked for being failed imports</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.ignoredPatterns')"
|
||||
title="Click for documentation"></i>
|
||||
Ignored Patterns
|
||||
@@ -219,7 +222,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.maxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
@@ -244,7 +247,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
|
||||
title="Click for documentation"></i>
|
||||
Reset Strikes On Progress
|
||||
@@ -257,27 +260,27 @@
|
||||
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be checked for being stalled</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
@@ -298,7 +301,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes for Downloading Metadata
|
||||
@@ -338,7 +341,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.maxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
@@ -363,7 +366,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
|
||||
title="Click for documentation"></i>
|
||||
Reset Strikes On Progress
|
||||
@@ -376,33 +379,33 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be checked for being slow</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.minSpeed')"
|
||||
title="Click for documentation"></i>
|
||||
Minimum Speed
|
||||
@@ -413,6 +416,7 @@
|
||||
[min]="0"
|
||||
placeholder="Enter minimum speed"
|
||||
helpText="Minimum speed threshold for slow downloads (e.g., 100KB/s)"
|
||||
type="speed"
|
||||
>
|
||||
</app-byte-size-input>
|
||||
</div>
|
||||
@@ -420,7 +424,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.maxTime')"
|
||||
title="Click for documentation"></i>
|
||||
Maximum Time (hours)
|
||||
@@ -443,7 +447,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.ignoreAboveSize')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Above Size
|
||||
@@ -454,6 +458,7 @@
|
||||
[min]="0"
|
||||
placeholder="Enter size threshold"
|
||||
helpText="Downloads will be ignored if size exceeds, e.g., 25 GB"
|
||||
type="size"
|
||||
>
|
||||
</app-byte-size-input>
|
||||
</div>
|
||||
@@ -488,3 +493,4 @@
|
||||
</form>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Queue Cleaner Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -176,25 +176,40 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
effect(() => {
|
||||
const config = this.queueCleanerConfig();
|
||||
if (config) {
|
||||
// Save original cron expression
|
||||
const cronExpression = config.cronExpression;
|
||||
// Handle the case where ignorePrivate is true but deletePrivate is also true
|
||||
// This shouldn't happen, but if it does, correct it
|
||||
const correctedConfig = { ...config };
|
||||
|
||||
// Reset form with the config values
|
||||
// For Queue Cleaner (apply to all sections)
|
||||
if (correctedConfig.failedImport?.ignorePrivate && correctedConfig.failedImport?.deletePrivate) {
|
||||
correctedConfig.failedImport.deletePrivate = false;
|
||||
}
|
||||
if (correctedConfig.stalled?.ignorePrivate && correctedConfig.stalled?.deletePrivate) {
|
||||
correctedConfig.stalled.deletePrivate = false;
|
||||
}
|
||||
if (correctedConfig.slow?.ignorePrivate && correctedConfig.slow?.deletePrivate) {
|
||||
correctedConfig.slow.deletePrivate = false;
|
||||
}
|
||||
|
||||
// Save original cron expression
|
||||
const cronExpression = correctedConfig.cronExpression;
|
||||
|
||||
// Reset form with the corrected config values
|
||||
this.queueCleanerForm.patchValue({
|
||||
enabled: config.enabled,
|
||||
useAdvancedScheduling: config.useAdvancedScheduling || false,
|
||||
cronExpression: config.cronExpression,
|
||||
jobSchedule: config.jobSchedule || {
|
||||
enabled: correctedConfig.enabled,
|
||||
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
|
||||
cronExpression: correctedConfig.cronExpression,
|
||||
jobSchedule: correctedConfig.jobSchedule || {
|
||||
every: 5,
|
||||
type: ScheduleUnit.Minutes
|
||||
},
|
||||
failedImport: config.failedImport,
|
||||
stalled: config.stalled,
|
||||
slow: config.slow,
|
||||
failedImport: correctedConfig.failedImport,
|
||||
stalled: correctedConfig.stalled,
|
||||
slow: correctedConfig.slow,
|
||||
});
|
||||
|
||||
// Then update all other dependent form control states
|
||||
this.updateFormControlDisabledStates(config);
|
||||
this.updateFormControlDisabledStates(correctedConfig);
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
@@ -255,6 +270,30 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.updateMainControlsState(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
// Add listeners for ignorePrivate changes in each section
|
||||
['failedImport', 'stalled', 'slow'].forEach(section => {
|
||||
const ignorePrivateControl = this.queueCleanerForm.get(`${section}.ignorePrivate`);
|
||||
|
||||
if (ignorePrivateControl) {
|
||||
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((ignorePrivate: boolean) => {
|
||||
const deletePrivateControl = this.queueCleanerForm.get(`${section}.deletePrivate`);
|
||||
|
||||
if (ignorePrivate && deletePrivateControl) {
|
||||
// If ignoring private, uncheck and disable delete private
|
||||
deletePrivateControl.setValue(false);
|
||||
deletePrivateControl.disable({ onlySelf: true });
|
||||
} else if (!ignorePrivate && deletePrivateControl) {
|
||||
// If not ignoring private, enable delete private (if parent section is enabled)
|
||||
const sectionEnabled = this.isSectionEnabled(section);
|
||||
if (sectionEnabled) {
|
||||
deletePrivateControl.enable({ onlySelf: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for changes to the 'useAdvancedScheduling' control
|
||||
const advancedControl = this.queueCleanerForm.get('useAdvancedScheduling');
|
||||
@@ -349,6 +388,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.originalFormValues = JSON.parse(JSON.stringify(this.queueCleanerForm.getRawValue()));
|
||||
this.hasActualChanges = false;
|
||||
}
|
||||
|
||||
// Helper method to check if a section is enabled
|
||||
private isSectionEnabled(section: string): boolean {
|
||||
const mainEnabled = this.queueCleanerForm.get('enabled')?.value || false;
|
||||
if (!mainEnabled) return false;
|
||||
|
||||
const maxStrikesControl = this.queueCleanerForm.get(`${section}.maxStrikes`);
|
||||
const maxStrikes = maxStrikesControl?.value || 0;
|
||||
|
||||
return maxStrikes >= 3;
|
||||
}
|
||||
|
||||
// Check if the current form values are different from the original values
|
||||
private formValuesChanged(): boolean {
|
||||
@@ -463,8 +513,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("ignoredPatterns")?.enable(options);
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.queueCleanerForm.get("failedImport.ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.queueCleanerForm.get("failedImport.deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable(options);
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable(options);
|
||||
}
|
||||
} else {
|
||||
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.disable(options);
|
||||
@@ -482,7 +541,16 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get("stalled")?.get("resetStrikesOnProgress")?.enable(options);
|
||||
this.queueCleanerForm.get("stalled")?.get("ignorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("stalled")?.get("deletePrivate")?.enable(options);
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.queueCleanerForm.get("stalled.ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.queueCleanerForm.get("stalled.deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable(options);
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable(options);
|
||||
}
|
||||
} else {
|
||||
this.queueCleanerForm.get("stalled")?.get("resetStrikesOnProgress")?.disable(options);
|
||||
this.queueCleanerForm.get("stalled")?.get("ignorePrivate")?.disable(options);
|
||||
@@ -500,10 +568,19 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get("slow")?.get("resetStrikesOnProgress")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("ignorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("deletePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("minSpeed")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("maxTime")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("ignoreAboveSize")?.enable(options);
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.queueCleanerForm.get("slow.ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.queueCleanerForm.get("slow.deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable(options);
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable(options);
|
||||
}
|
||||
} else {
|
||||
this.queueCleanerForm.get("slow")?.get("resetStrikesOnProgress")?.disable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("ignorePrivate")?.disable(options);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="settings-container">
|
||||
<!-- Toast for notifications -->
|
||||
<p-toast></p-toast>
|
||||
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- General Settings Component -->
|
||||
<div class="mb-4">
|
||||
<app-general-settings></app-general-settings>
|
||||
</div>
|
||||
|
||||
<!-- Queue Cleaner Component -->
|
||||
<div class="mb-4">
|
||||
<app-queue-cleaner-settings></app-queue-cleaner-settings>
|
||||
</div>
|
||||
|
||||
<!-- Content Blocker Component -->
|
||||
<div class="mb-4">
|
||||
<app-content-blocker-settings></app-content-blocker-settings>
|
||||
</div>
|
||||
|
||||
<!-- Download Cleaner Component -->
|
||||
<div class="mb-4">
|
||||
<app-download-cleaner-settings></app-download-cleaner-settings>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SettingsPageComponent } from './settings-page.component';
|
||||
|
||||
describe('SettingsPageComponent', () => {
|
||||
let component: SettingsPageComponent;
|
||||
let fixture: ComponentFixture<SettingsPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SettingsPageComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { PanelModule } from 'primeng/panel';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
import { CanComponentDeactivate } from '../../core/guards';
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { MessageService, ConfirmationService } from 'primeng/api';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
|
||||
// Custom Components and Services
|
||||
import { QueueCleanerSettingsComponent } from '../queue-cleaner/queue-cleaner-settings.component';
|
||||
import { GeneralSettingsComponent } from '../general-settings/general-settings.component';
|
||||
import { DownloadCleanerSettingsComponent } from '../download-cleaner/download-cleaner-settings.component';
|
||||
import { SonarrSettingsComponent } from '../sonarr/sonarr-settings.component';
|
||||
import { ContentBlockerSettingsComponent } from "../content-blocker/content-blocker-settings.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
PanelModule,
|
||||
ButtonModule,
|
||||
DropdownModule,
|
||||
CardModule,
|
||||
ToastModule,
|
||||
ConfirmDialogModule,
|
||||
QueueCleanerSettingsComponent,
|
||||
GeneralSettingsComponent,
|
||||
DownloadCleanerSettingsComponent,
|
||||
ContentBlockerSettingsComponent
|
||||
],
|
||||
providers: [MessageService, ConfirmationService],
|
||||
templateUrl: './settings-page.component.html',
|
||||
styleUrl: './settings-page.component.scss'
|
||||
})
|
||||
export class SettingsPageComponent implements CanComponentDeactivate {
|
||||
// Reference to the settings components
|
||||
@ViewChild(QueueCleanerSettingsComponent) queueCleanerSettings!: QueueCleanerSettingsComponent;
|
||||
@ViewChild(GeneralSettingsComponent) generalSettings!: GeneralSettingsComponent;
|
||||
@ViewChild(DownloadCleanerSettingsComponent) downloadCleanerSettings!: DownloadCleanerSettingsComponent;
|
||||
@ViewChild(SonarrSettingsComponent) sonarrSettings!: SonarrSettingsComponent;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Future implementation for other settings sections
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements CanComponentDeactivate interface
|
||||
* Check if any settings components have unsaved changes
|
||||
*/
|
||||
canDeactivate(): boolean {
|
||||
// Check if queue cleaner settings has unsaved changes
|
||||
if (this.queueCleanerSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if general settings has unsaved changes
|
||||
if (this.generalSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if download cleaner settings has unsaved changes
|
||||
if (this.downloadCleanerSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if sonarr settings has unsaved changes
|
||||
if (this.sonarrSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
margin-right: 0.5rem;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
font-size: 0.9em;
|
||||
font-size: 1em;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModu
|
||||
import { InputNumberModule } from 'primeng/inputnumber';
|
||||
import { SelectButtonModule } from 'primeng/selectbutton';
|
||||
|
||||
type ByteSizeUnit = 'KB' | 'MB' | 'GB' | 'TB';
|
||||
export type ByteSizeInputType = 'speed' | 'size';
|
||||
|
||||
type ByteSizeUnit = 'KB' | 'MB' | 'GB';
|
||||
|
||||
@Component({
|
||||
selector: 'app-byte-size-input',
|
||||
@@ -26,31 +28,57 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
|
||||
@Input() disabled: boolean = false;
|
||||
@Input() placeholder: string = 'Enter size';
|
||||
@Input() helpText: string = '';
|
||||
@Input() type: ByteSizeInputType = 'size';
|
||||
|
||||
// Value in the selected unit
|
||||
value = signal<number | null>(null);
|
||||
|
||||
|
||||
// The selected unit
|
||||
unit = signal<ByteSizeUnit>('MB');
|
||||
|
||||
// Available units
|
||||
unitOptions = [
|
||||
{ label: 'KB', value: 'KB' },
|
||||
{ label: 'MB', value: 'MB' },
|
||||
{ label: 'GB', value: 'GB' },
|
||||
{ label: 'TB', value: 'TB' }
|
||||
];
|
||||
|
||||
// Available units, computed based on type
|
||||
get unitOptions() {
|
||||
switch (this.type) {
|
||||
case 'speed':
|
||||
return [
|
||||
{ label: 'KB/s', value: 'KB' },
|
||||
{ label: 'MB/s', value: 'MB' }
|
||||
];
|
||||
case 'size':
|
||||
default:
|
||||
return [
|
||||
{ label: 'MB', value: 'MB' },
|
||||
{ label: 'GB', value: 'GB' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Get default unit based on type
|
||||
private getDefaultUnit(): ByteSizeUnit {
|
||||
switch (this.type) {
|
||||
case 'speed':
|
||||
return 'KB';
|
||||
case 'size':
|
||||
default:
|
||||
return 'MB';
|
||||
}
|
||||
}
|
||||
|
||||
// ControlValueAccessor interface methods
|
||||
private onChange: (value: string) => void = () => {};
|
||||
private onTouched: () => void = () => {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the string value in format '100MB', '1.5GB', etc.
|
||||
*/
|
||||
writeValue(value: string): void {
|
||||
if (!value) {
|
||||
this.value.set(null);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,15 +90,24 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
|
||||
if (match) {
|
||||
const numValue = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase() as ByteSizeUnit;
|
||||
|
||||
this.value.set(numValue);
|
||||
this.unit.set(unit);
|
||||
// Validate unit is allowed for this type
|
||||
const allowedUnits = this.unitOptions.map(opt => opt.value);
|
||||
if (allowedUnits.includes(unit)) {
|
||||
this.value.set(numValue);
|
||||
this.unit.set(unit);
|
||||
} else {
|
||||
// If unit not allowed, use default
|
||||
this.value.set(numValue);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
} else {
|
||||
this.value.set(null);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing byte size value:', value, e);
|
||||
this.value.set(null);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +128,10 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
|
||||
*/
|
||||
updateValue(): void {
|
||||
this.onTouched();
|
||||
|
||||
if (this.value() === null) {
|
||||
this.onChange('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format as "100MB", "1.5GB", etc.
|
||||
const formattedValue = `${this.value()}${this.unit()}`;
|
||||
this.onChange(formattedValue);
|
||||
|
||||
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;
|
||||
@@ -37,6 +37,7 @@ export interface ContentBlockerConfig {
|
||||
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
deleteKnownMalware: boolean;
|
||||
|
||||
sonarr: BlocklistSettings;
|
||||
radarr: BlocklistSettings;
|
||||
@@ -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
|
||||
@@ -83,6 +83,25 @@ Setting this to true means private torrents will be permanently deleted, potenti
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
id="delete-known-malware"
|
||||
title="Delete Known Malware"
|
||||
icon="🦠"
|
||||
>
|
||||
|
||||
When enabled, downloads that match known malware patterns will be automatically deleted from the download client.
|
||||
|
||||
**Malware Detection Source:**
|
||||
- List is automatically fetched from: `https://cleanuparr.pages.dev/static/known_malware_file_name_patterns`
|
||||
- Updates automatically every **5 minutes**
|
||||
- Contains filename patterns known to be associated with malware
|
||||
|
||||
<EnhancedWarning>
|
||||
This feature permanently deletes downloads that match malware patterns. While the patterns are carefully curated, false positives are possible. Monitor logs carefully when first enabling this feature.
|
||||
</EnhancedWarning>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
@@ -102,7 +121,7 @@ Setting this to true means private torrents will be permanently deleted, potenti
|
||||
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>
|
||||
|
||||
@@ -118,7 +137,12 @@ Path to the blocklist file or URL. This can be a local file path or a remote URL
|
||||
- `/config/sonarr-blocklist.txt`
|
||||
- `https://example.com/blocklist.txt`
|
||||
|
||||
The blocklists support the following types of patters:
|
||||
**Automatic Reload Intervals:**
|
||||
- **Cleanuparr Official Lists** (`cleanuparr.pages.dev`): Every **5 minutes**
|
||||
- **Other Remote URLs**: Every **4 hours**
|
||||
- **Local Files**: Every **5 minutes**
|
||||
|
||||
The blocklists support the following types of patterns:
|
||||
```
|
||||
*example // file name ends with "example"
|
||||
example* // file name starts with "example"
|
||||
@@ -127,6 +151,14 @@ example // file name is exactly the word "example"
|
||||
regex:<ANY_REGEX> // regex that needs to be marked at the start of the line with "regex:"
|
||||
```
|
||||
|
||||
:::tip
|
||||
Available blocklists that can be used with Sonarr and Radarr:
|
||||
- `http://cleanuparr.pages.dev/static/blacklist`
|
||||
- `http://cleanuparr.pages.dev/static/blacklist_permissive`
|
||||
- `http://cleanuparr.pages.dev/static/whitelist`
|
||||
- `http://cleanuparr.pages.dev/static/whitelist_with_subtitles`
|
||||
:::
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
@@ -139,10 +171,6 @@ Controls how the blocklist is interpreted:
|
||||
- **Blacklist**: Files matching any pattern in the list will be blocked.
|
||||
- **Whitelist**: Only files matching patterns in the list will be allowed.
|
||||
|
||||
:::tip
|
||||
[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive), [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) and [this whitelist with subtitles](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist_with_subtitles) can be used for Sonarr and Radarr.
|
||||
:::
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
@@ -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