Compare commits

..

17 Commits

Author SHA1 Message Date
Flaminel
b834078c11 updated qBit package 2025-08-14 23:32:08 +03:00
Flaminel
9cc36c7a50 Add qBittorrent basic auth support (#246) 2025-08-11 10:52:44 +03:00
Flaminel
861c135cc6 fixed Malware Blocker docs path 2025-08-07 11:55:46 +03:00
Flaminel
3b0275c411 Finish rebranding Content Blocker to Malware Blocker (#271) 2025-08-06 22:55:39 +03:00
Flaminel
cad1b51202 Improve logs and events ordering to be descending from the top (#270) 2025-08-06 22:51:20 +03:00
Flaminel
f50acd29f4 Disable MassTransit telemetry (#268) 2025-08-06 22:50:48 +03:00
LucasFA
af11d595d8 Fix detailed installation docs (#260)
https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed
2025-08-06 22:49:14 +03:00
Flaminel
44994d5b21 Fix Notifiarr channel id input (#267) 2025-08-04 22:07:33 +03:00
Flaminel
592fd2d846 Fix Malware Blocker renaming issue (#259) 2025-08-02 15:54:26 +03:00
Flaminel
e96be1fca2 Small general fixes (#257)
* renamed ContentBlocker into MalwareBlocker in the logs

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

@@ -15,7 +15,8 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads that are **failing to be imported** by the arrs.
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
@@ -25,21 +26,24 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
## 🎯 Supported Applications
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
- **µTorrent**
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
## 🚀 Quick Start
@@ -55,7 +59,7 @@ docker run -d --name cleanuparr \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface

View File

@@ -11,9 +11,9 @@ using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Mapster;
@@ -65,8 +65,8 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("content_blocker")]
public async Task<IActionResult> GetContentBlockerConfig()
[HttpGet("malware_blocker")]
public async Task<IActionResult> GetMalwareBlockerConfig()
{
await DataContext.Lock.WaitAsync();
try
@@ -483,8 +483,8 @@ public class ConfigurationController : ControllerBase
}
}
[HttpPut("content_blocker")]
public async Task<IActionResult> UpdateContentBlockerConfig([FromBody] ContentBlockerConfig newConfig)
[HttpPut("malware_blocker")]
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] ContentBlockerConfig newConfig)
{
await DataContext.Lock.WaitAsync();
try
@@ -495,7 +495,7 @@ public class ConfigurationController : ControllerBase
// Validate cron expression if present
if (!string.IsNullOrEmpty(newConfig.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.ContentBlocker);
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.MalwareBlocker);
}
// Get existing config
@@ -513,13 +513,13 @@ public class ConfigurationController : ControllerBase
await _dataContext.SaveChangesAsync();
// Update the scheduler based on configuration changes
await UpdateJobSchedule(oldConfig, JobType.ContentBlocker);
await UpdateJobSchedule(oldConfig, JobType.MalwareBlocker);
return Ok(new { Message = "ContentBlocker configuration updated successfully" });
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save ContentBlocker configuration");
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
throw;
}
finally

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
@@ -94,7 +94,7 @@ public class BackgroundJobManager : IHostedService
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
ContentBlockerConfig contentBlockerConfig = await dataContext.ContentBlockerConfigs
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
@@ -103,7 +103,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
}
@@ -127,17 +127,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
/// </summary>
public async Task RegisterContentBlockerJob(
public async Task RegisterMalwareBlockerJob(
ContentBlockerConfig config,
CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
}
}
@@ -190,7 +190,7 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}

View File

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

@@ -3,29 +3,29 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
namespace Cleanuparr.Application.Features.ContentBlocker;
namespace Cleanuparr.Application.Features.MalwareBlocker;
public sealed class ContentBlocker : GenericHandler
public sealed class MalwareBlocker : GenericHandler
{
private readonly BlocklistProvider _blocklistProvider;
public ContentBlocker(
ILogger<ContentBlocker> logger,
public MalwareBlocker(
ILogger<MalwareBlocker> logger,
DataContext dataContext,
IMemoryCache cache,
IBus messageBus,
@@ -66,27 +66,27 @@ public sealed class ContentBlocker : GenericHandler
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled)
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
}
if (config.Radarr.Enabled)
if (config.Radarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
}
if (config.Lidarr.Enabled)
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
if (config.Readarr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled)
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,4 +5,5 @@ public enum DownloadClientTypeName
qBittorrent,
Deluge,
Transmission,
}
uTorrent,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,10 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Cache;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
@@ -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

@@ -1,8 +1,8 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
@@ -49,6 +50,7 @@ public sealed class DownloadServiceFactory
DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig),
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -115,4 +117,26 @@ public sealed class DownloadServiceFactory
return service;
}
private UTorrentService CreateUTorrentService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<UTorrentService>>();
var cache = _serviceProvider.GetRequiredService<IMemoryCache>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<EventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<BlocklistProvider>();
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
// Create the UTorrentService instance
UTorrentService service = new(
logger, cache, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory
);
return service;
}
}

View File

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

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using QBittorrent.Client;
@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
_logger.LogError("Failed to find torrent properties {name}", download.Name);
return result;
}
@@ -49,9 +49,9 @@ public partial class QBitService
result.IsPrivate = isPrivate;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && isPrivate)
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -60,9 +60,9 @@ public partial class QBitService
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
if (files is null)
if (files?.Count is null or 0)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("skip files check | no files found | {name}", download.Name);
return result;
}
@@ -74,6 +74,7 @@ public partial class QBitService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
foreach (TorrentContent file in files)
{
@@ -85,7 +86,7 @@ public partial class QBitService
totalFiles++;
if (IsDefinitelyMalware(file.Name))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC.Entity;
@@ -40,9 +40,9 @@ public partial class TransmissionService
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
var contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (contentBlockerConfig.IgnorePrivate && isPrivate)
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
@@ -57,6 +57,7 @@ public partial class TransmissionService
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
for (int i = 0; i < download.Files.Length; i++)
{
@@ -68,7 +69,7 @@ public partial class TransmissionService
totalFiles++;
if (IsDefinitelyMalware(download.Files[i].Name))
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,15 +6,14 @@ using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public sealed class BlocklistProvider
{
@@ -23,8 +22,11 @@ public sealed class BlocklistProvider
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly Dictionary<InstanceType, string> _configHashes = new();
private static DateTime _lastLoadTime = DateTime.MinValue;
private const int LoadIntervalHours = 4;
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
private const int DefaultLoadIntervalHours = 4;
private const int FastLoadIntervalMinutes = 5;
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
private const string MalwareListKey = "MALWARE_PATTERNS";
public BlocklistProvider(
ILogger<BlocklistProvider> logger,
@@ -46,78 +48,89 @@ public sealed class BlocklistProvider
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
int changedCount = 0;
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync();
bool shouldReload = false;
if (_lastLoadTime.AddHours(LoadIntervalHours) < DateTime.UtcNow)
{
shouldReload = true;
_lastLoadTime = DateTime.UtcNow;
}
if (!contentBlockerConfig.Enabled)
if (!malwareBlockerConfig.Enabled)
{
_logger.LogDebug("Content blocker is disabled, skipping blocklist loading");
_logger.LogDebug("Malware Blocker is disabled, skipping blocklist loading");
return;
}
// Check and update Sonarr blocklist if needed
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
string sonarrHash = GenerateSettingsHash(malwareBlockerConfig.Sonarr);
var sonarrInterval = GetLoadInterval(malwareBlockerConfig.Sonarr.BlocklistPath);
var sonarrIdentifier = $"Sonarr_{malwareBlockerConfig.Sonarr.BlocklistPath}";
if (ShouldReloadBlocklist(sonarrIdentifier, sonarrInterval) || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
{
_logger.LogDebug("Loading Sonarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Sonarr, InstanceType.Sonarr);
_configHashes[InstanceType.Sonarr] = sonarrHash;
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Radarr blocklist if needed
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
string radarrHash = GenerateSettingsHash(malwareBlockerConfig.Radarr);
var radarrInterval = GetLoadInterval(malwareBlockerConfig.Radarr.BlocklistPath);
var radarrIdentifier = $"Radarr_{malwareBlockerConfig.Radarr.BlocklistPath}";
if (ShouldReloadBlocklist(radarrIdentifier, radarrInterval) || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
{
_logger.LogDebug("Loading Radarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Radarr, InstanceType.Radarr);
_configHashes[InstanceType.Radarr] = radarrHash;
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
string lidarrHash = GenerateSettingsHash(malwareBlockerConfig.Lidarr);
var lidarrInterval = GetLoadInterval(malwareBlockerConfig.Lidarr.BlocklistPath);
var lidarrIdentifier = $"Lidarr_{malwareBlockerConfig.Lidarr.BlocklistPath}";
if (ShouldReloadBlocklist(lidarrIdentifier, lidarrInterval) || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
{
_logger.LogDebug("Loading Lidarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Lidarr, InstanceType.Lidarr);
_configHashes[InstanceType.Lidarr] = lidarrHash;
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
// Check and update Readarr blocklist if needed
string readarrHash = GenerateSettingsHash(malwareBlockerConfig.Readarr);
var readarrInterval = GetLoadInterval(malwareBlockerConfig.Readarr.BlocklistPath);
var readarrIdentifier = $"Readarr_{malwareBlockerConfig.Readarr.BlocklistPath}";
if (ShouldReloadBlocklist(readarrIdentifier, readarrInterval) || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
string whisparrHash = GenerateSettingsHash(malwareBlockerConfig.Whisparr);
var whisparrInterval = GetLoadInterval(malwareBlockerConfig.Whisparr.BlocklistPath);
var whisparrIdentifier = $"Whisparr_{malwareBlockerConfig.Whisparr.BlocklistPath}";
if (ShouldReloadBlocklist(whisparrIdentifier, whisparrInterval) || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync();
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
@@ -154,6 +167,77 @@ public sealed class BlocklistProvider
return regexes ?? [];
}
public ConcurrentBag<string> GetMalwarePatterns()
{
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
return patterns ?? [];
}
private TimeSpan GetLoadInterval(string? path)
{
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
{
if (uri.Host.Equals("cleanuparr.pages.dev", StringComparison.OrdinalIgnoreCase))
{
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
}
return TimeSpan.FromHours(DefaultLoadIntervalHours);
}
// If fast load interval for local files
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
}
private bool ShouldReloadBlocklist(string identifier, TimeSpan interval)
{
if (!_lastLoadTimes.TryGetValue(identifier, out DateTime lastLoad))
{
return true;
}
return DateTime.UtcNow - lastLoad >= interval;
}
private async Task LoadMalwarePatternsAsync()
{
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
{
return;
}
try
{
_logger.LogDebug("Loading malware patterns");
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
ConcurrentBag<string> patterns = [];
Parallel.ForEach(filePatterns, options, pattern =>
{
patterns.Add(pattern);
});
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
}
}
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
{

View File

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

@@ -2,9 +2,11 @@
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
public interface IFilenameEvaluator
{
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -2,7 +2,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
public sealed record ContentBlockerConfig : IJobConfig
{
@@ -19,6 +19,8 @@ public sealed record ContentBlockerConfig : IJobConfig
public bool IgnorePrivate { get; set; }
public bool DeletePrivate { get; set; }
public bool DeleteKnownMalware { get; set; }
public BlocklistSettings Sonarr { get; set; } = new();

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,14 +63,15 @@ export class DocumentationService {
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories'
},
'content-blocker': {
'enabled': 'enable-content-blocker',
'malware-blocker': {
'enabled': 'enable-malware-blocker',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'jobSchedule.every': 'run-schedule',
'jobSchedule.type': 'run-schedule',
'ignorePrivate': 'ignore-private',
'deletePrivate': 'delete-private',
'deleteKnownMalware': 'delete-known-malware',
'sonarr.enabled': 'enable-sonarr-blocklist',
'sonarr.blocklistPath': 'sonarr-blocklist-path',
'sonarr.blocklistType': 'sonarr-blocklist-type',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,7 +115,7 @@
<form [formGroup]="clientForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
pTooltip="Click for documentation"></i>
Enabled
@@ -128,7 +128,7 @@
<div class="field">
<label for="client-name">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('name')"
pTooltip="Click for documentation"></i>
Name *
@@ -146,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

@@ -330,6 +330,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
case DownloadClientTypeName.qBittorrent:
case DownloadClientTypeName.Deluge:
case DownloadClientTypeName.Transmission:
case DownloadClientTypeName.uTorrent:
return DownloadClientType.Torrent;
default:
throw new Error(`Unknown client type name: ${typeName}`);

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,48 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Malware Blocker</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Content Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Malware Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Loading/Error State Component -->
<app-loading-error-state
*ngIf="contentBlockerLoading() || contentBlockerLoadError()"
[loading]="contentBlockerLoading()"
[error]="contentBlockerLoadError()"
*ngIf="malwareBlockerLoading() || malwareBlockerLoadError()"
[loading]="malwareBlockerLoading()"
[error]="malwareBlockerLoadError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Form Content - only shown when not loading and no error -->
<form *ngIf="!contentBlockerLoading() && !contentBlockerLoadError()" [formGroup]="contentBlockerForm" class="p-fluid">
<form *ngIf="!malwareBlockerLoading() && !malwareBlockerLoadError()" [formGroup]="malwareBlockerForm" class="p-fluid">
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Content Blocker
Enable Malware Blocker
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the content blocker will run according to the schedule</small>
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
</div>
</div>
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -59,7 +62,7 @@
</div>
<!-- Basic Schedule Controls - shown when useAdvancedScheduling is false -->
<div class="field-row" formGroupName="jobSchedule" *ngIf="!contentBlockerForm.get('useAdvancedScheduling')?.value">
<div class="field-row" formGroupName="jobSchedule" *ngIf="!malwareBlockerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
Run Schedule
</label>
@@ -85,14 +88,14 @@
</p-selectButton>
</div>
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
<small class="form-helper-text">How often the content blocker should run</small>
<small class="form-helper-text">How often the Malware Blocker should run</small>
</div>
</div>
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
<div class="field-row" *ngIf="malwareBlockerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -109,34 +112,47 @@
<!-- Content Blocker Specific Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
<small class="form-helper-text">When enabled, private torrents will not be processed</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deleteKnownMalware')"
title="Click for documentation"></i>
Delete Known Malware
</label>
<div class="field-input">
<p-checkbox formControlName="deleteKnownMalware" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, downloads matching known malware patterns will be deleted</small>
</div>
</div>
<!-- Arr Service Settings in Accordion -->
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Sonarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="0">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -151,7 +167,7 @@
<div formGroupName="sonarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.enabled')"
title="Click for documentation"></i>
Enable Sonarr Blocklist
@@ -164,7 +180,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -180,7 +196,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -206,7 +222,7 @@
</p-accordion-panel>
<!-- Radarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="1">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -221,7 +237,7 @@
<div formGroupName="radarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.enabled')"
title="Click for documentation"></i>
Enable Radarr Blocklist
@@ -234,7 +250,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -250,7 +266,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -276,7 +292,7 @@
</p-accordion-panel>
<!-- Lidarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="2">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="2">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -291,7 +307,7 @@
<div formGroupName="lidarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.enabled')"
title="Click for documentation"></i>
Enable Lidarr Blocklist
@@ -304,7 +320,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -320,7 +336,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -346,7 +362,7 @@
</p-accordion-panel>
<!-- Readarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -361,7 +377,7 @@
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
@@ -374,7 +390,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -390,7 +406,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -416,7 +432,7 @@
</p-accordion-panel>
<!-- Whisparr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="4">
<p-accordion-panel [disabled]="!malwareBlockerForm.get('enabled')?.value" [value]="4">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
@@ -431,7 +447,7 @@
<div formGroupName="whisparr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.enabled')"
title="Click for documentation"></i>
Enable Whisparr Blocklist
@@ -444,7 +460,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -460,7 +476,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -494,9 +510,9 @@
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!contentBlockerForm.dirty || !hasActualChanges) || contentBlockerForm.invalid || contentBlockerSaving()"
[loading]="contentBlockerSaving()"
(click)="saveContentBlockerConfig()"
[disabled]="(!malwareBlockerForm.dirty || !hasActualChanges) || malwareBlockerForm.invalid || malwareBlockerSaving()"
[loading]="malwareBlockerSaving()"
(click)="saveMalwareBlockerConfig()"
></button>
<button
pButton
@@ -504,9 +520,10 @@
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetContentBlockerConfig()"
(click)="resetMalwareBlockerConfig()"
></button>
</div>
</form>
</div>
</p-card>
</p-card>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

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