Compare commits

...

7 Commits

Author SHA1 Message Date
Flaminel
e96be1fca2 Small general fixes (#257)
* renamed ContentBlocker into MalwareBlocker in the logs

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

View File

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

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

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

3
Cloudflare/_headers Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -66,27 +66,27 @@ public sealed class ContentBlocker : GenericHandler
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled)
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
}
if (config.Radarr.Enabled)
if (config.Radarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
}
if (config.Lidarr.Enabled)
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled)
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,7 @@ public partial class QBitService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
foreach (TorrentContent file in files)
{
@@ -85,7 +86,7 @@ public partial class QBitService
totalFiles++;
if (IsDefinitelyMalware(file.Name))
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;

View File

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

View File

@@ -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 (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
result.ShouldRemove = true;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,14 +41,14 @@
</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>
@@ -56,15 +56,21 @@
</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>
<p-inputNumber
placeholder="Enter channel ID"
formControlName="channelId"
[min]="0"
[useGrouping]="false"
>
</p-inputNumber>
<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 +116,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 +131,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 +146,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 +161,7 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>

View File

@@ -9,6 +9,7 @@ import { NotificationsConfig } from "../../shared/models/notifications-config.mo
// 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,6 +25,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
ReactiveFormsModule,
CardModule,
InputTextModule,
InputNumberModule,
CheckboxModule,
ButtonModule,
ToastModule,
@@ -221,7 +223,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
const formValues = this.notificationForm.value;
const config: NotificationsConfig = {
notifiarr: formValues.notifiarr,
notifiarr: {
...formValues.notifiarr,
channelId: formValues.notifiarr.channelId ? formValues.notifiarr.channelId.toString() : null,
},
apprise: formValues.apprise,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ export interface ContentBlockerConfig {
ignorePrivate: boolean;
deletePrivate: boolean;
deleteKnownMalware: boolean;
sonarr: BlocklistSettings;
radarr: BlocklistSettings;

View File

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