mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac | ||
|
|
323bfc4d2e | ||
|
|
dca45585ca | ||
|
|
8b5918d221 | ||
|
|
9c227c1f59 | ||
|
|
2ad4499a6f | ||
|
|
33a5bf9ab3 | ||
|
|
de06d1c2d3 | ||
|
|
72855bc030 | ||
|
|
b185ea6899 |
14
.github/workflows/build-docker.yml
vendored
14
.github/workflows/build-docker.yml
vendored
@@ -29,6 +29,8 @@ jobs:
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
@@ -36,6 +38,12 @@ jobs:
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
@@ -58,6 +66,12 @@ jobs:
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
|
||||
36
.github/workflows/cloudflare-pages.yml
vendored
Normal file
36
.github/workflows/cloudflare-pages.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'Cloudflare/**'
|
||||
- 'blacklist'
|
||||
- 'blacklist_permissive'
|
||||
- 'whitelist'
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
cp blacklist Cloudflare/static/
|
||||
cp blacklist_permissive Cloudflare/static/
|
||||
cp whitelist Cloudflare/static/
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
3
Cloudflare/_headers
Normal file
3
Cloudflare/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
@@ -0,0 +1,2 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
21
README.md
21
README.md
@@ -25,21 +25,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 +58,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
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ public class ConfigurationController : ControllerBase
|
||||
// Validate cron expression if present
|
||||
if (!string.IsNullOrEmpty(newConfig.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.ContentBlocker);
|
||||
CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.MalwareBlocker);
|
||||
}
|
||||
|
||||
// Get existing config
|
||||
@@ -513,7 +513,7 @@ public class ConfigurationController : ControllerBase
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Update the scheduler based on configuration changes
|
||||
await UpdateJobSchedule(oldConfig, JobType.ContentBlocker);
|
||||
await UpdateJobSchedule(oldConfig, JobType.MalwareBlocker);
|
||||
|
||||
return Ok(new { Message = "ContentBlocker configuration updated successfully" });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
public enum DownloadClientTypeName
|
||||
{
|
||||
QBittorrent,
|
||||
qBittorrent,
|
||||
Deluge,
|
||||
Transmission,
|
||||
}
|
||||
uTorrent,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Deluge.Exceptions;
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public class DelugeClientException : Exception
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Deluge.Exceptions;
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public sealed class DelugeLoginException : DelugeClientException
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Deluge.Exceptions;
|
||||
namespace Cleanuparr.Domain.Exceptions;
|
||||
|
||||
public sealed class DelugeLogoutException : DelugeClientException
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,11 @@ public sealed class BlocklistProvider
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Dictionary<InstanceType, string> _configHashes = new();
|
||||
private static DateTime _lastLoadTime = DateTime.MinValue;
|
||||
private const int LoadIntervalHours = 4;
|
||||
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
|
||||
private const int DefaultLoadIntervalHours = 4;
|
||||
private const int FastLoadIntervalMinutes = 5;
|
||||
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
|
||||
private const string MalwareListKey = "MALWARE_PATTERNS";
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
@@ -49,13 +52,6 @@ public sealed class BlocklistProvider
|
||||
var contentBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
bool shouldReload = false;
|
||||
|
||||
if (_lastLoadTime.AddHours(LoadIntervalHours) < DateTime.UtcNow)
|
||||
{
|
||||
shouldReload = true;
|
||||
_lastLoadTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (!contentBlockerConfig.Enabled)
|
||||
{
|
||||
@@ -65,59 +61,77 @@ public sealed class BlocklistProvider
|
||||
|
||||
// Check and update Sonarr blocklist if needed
|
||||
string sonarrHash = GenerateSettingsHash(contentBlockerConfig.Sonarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
|
||||
var sonarrInterval = GetLoadInterval(contentBlockerConfig.Sonarr.BlocklistPath);
|
||||
var sonarrIdentifier = $"Sonarr_{contentBlockerConfig.Sonarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(sonarrIdentifier, sonarrInterval) || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Sonarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Sonarr, InstanceType.Sonarr);
|
||||
_configHashes[InstanceType.Sonarr] = sonarrHash;
|
||||
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Radarr blocklist if needed
|
||||
string radarrHash = GenerateSettingsHash(contentBlockerConfig.Radarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
|
||||
var radarrInterval = GetLoadInterval(contentBlockerConfig.Radarr.BlocklistPath);
|
||||
var radarrIdentifier = $"Radarr_{contentBlockerConfig.Radarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(radarrIdentifier, radarrInterval) || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Radarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Radarr, InstanceType.Radarr);
|
||||
_configHashes[InstanceType.Radarr] = radarrHash;
|
||||
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Lidarr blocklist if needed
|
||||
string lidarrHash = GenerateSettingsHash(contentBlockerConfig.Lidarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
|
||||
var lidarrInterval = GetLoadInterval(contentBlockerConfig.Lidarr.BlocklistPath);
|
||||
var lidarrIdentifier = $"Lidarr_{contentBlockerConfig.Lidarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(lidarrIdentifier, lidarrInterval) || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Lidarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Lidarr, InstanceType.Lidarr);
|
||||
_configHashes[InstanceType.Lidarr] = lidarrHash;
|
||||
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Lidarr blocklist if needed
|
||||
// Check and update Readarr blocklist if needed
|
||||
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
|
||||
var readarrInterval = GetLoadInterval(contentBlockerConfig.Readarr.BlocklistPath);
|
||||
var readarrIdentifier = $"Readarr_{contentBlockerConfig.Readarr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(readarrIdentifier, readarrInterval) || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Readarr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
|
||||
_configHashes[InstanceType.Readarr] = readarrHash;
|
||||
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Check and update Whisparr blocklist if needed
|
||||
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
|
||||
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
|
||||
var whisparrInterval = GetLoadInterval(contentBlockerConfig.Whisparr.BlocklistPath);
|
||||
var whisparrIdentifier = $"Whisparr_{contentBlockerConfig.Whisparr.BlocklistPath}";
|
||||
if (ShouldReloadBlocklist(whisparrIdentifier, whisparrInterval) || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
|
||||
{
|
||||
_logger.LogDebug("Loading Whisparr blocklist");
|
||||
|
||||
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
|
||||
_configHashes[InstanceType.Whisparr] = whisparrHash;
|
||||
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
|
||||
changedCount++;
|
||||
}
|
||||
|
||||
// Always check and update malware patterns
|
||||
await LoadMalwarePatternsAsync();
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
|
||||
@@ -154,6 +168,77 @@ public sealed class BlocklistProvider
|
||||
|
||||
return regexes ?? [];
|
||||
}
|
||||
|
||||
public ConcurrentBag<string> GetMalwarePatterns()
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
|
||||
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
private TimeSpan GetLoadInterval(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (uri.Host.Equals("cleanuparr.pages.dev", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
}
|
||||
|
||||
return TimeSpan.FromHours(DefaultLoadIntervalHours);
|
||||
}
|
||||
|
||||
// If fast load interval for local files
|
||||
return TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
}
|
||||
|
||||
private bool ShouldReloadBlocklist(string identifier, TimeSpan interval)
|
||||
{
|
||||
if (!_lastLoadTimes.TryGetValue(identifier, out DateTime lastLoad))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateTime.UtcNow - lastLoad >= interval;
|
||||
}
|
||||
|
||||
private async Task LoadMalwarePatternsAsync()
|
||||
{
|
||||
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
|
||||
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Loading malware patterns");
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
ConcurrentBag<string> patterns = [];
|
||||
|
||||
Parallel.ForEach(filePatterns, options, pattern =>
|
||||
{
|
||||
patterns.Add(pattern);
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
|
||||
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,4 +7,6 @@ namespace Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
public interface IFilenameEvaluator
|
||||
{
|
||||
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
|
||||
|
||||
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -69,6 +69,7 @@ public partial class DelugeService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
@@ -80,7 +81,7 @@ public partial class DelugeService
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsDefinitelyMalware(name))
|
||||
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,9 +47,10 @@ public sealed class DownloadServiceFactory
|
||||
|
||||
return downloadClientConfig.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.QBittorrent => CreateQBitService(downloadClientConfig),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public partial class TransmissionService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
@@ -68,7 +69,7 @@ public partial class TransmissionService
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (IsDefinitelyMalware(download.Files[i].Name))
|
||||
if (contentBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for µTorrent download service
|
||||
/// </summary>
|
||||
public interface IUTorrentService : IDownloadService
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.ContentBlocker;
|
||||
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 contentBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (contentBlockerConfig.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 (contentBlockerConfig.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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ namespace Cleanuparr.Infrastructure.Models;
|
||||
public enum JobType
|
||||
{
|
||||
QueueCleaner,
|
||||
ContentBlocker,
|
||||
MalwareBlocker,
|
||||
DownloadCleaner
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public static class CronValidationHelper
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (jobType is not JobType.ContentBlocker && triggerValue < Constants.TriggerMinLimit)
|
||||
if (jobType is not JobType.MalwareBlocker && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
|
||||
645
code/backend/Cleanuparr.Persistence/Migrations/Data/20250801143446_AddKnownMalwareOption.Designer.cs
generated
Normal file
645
code/backend/Cleanuparr.Persistence/Migrations/Data/20250801143446_AddKnownMalwareOption.Designer.cs
generated
Normal file
@@ -0,0 +1,645 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250801143446_AddKnownMalwareOption")]
|
||||
partial class AddKnownMalwareOption
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<short>("FailedImportMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_configs");
|
||||
|
||||
b.ToTable("arr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<Guid>("ArrConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_instances");
|
||||
|
||||
b.HasIndex("ArrConfigId")
|
||||
.HasDatabaseName("ix_arr_instances_arr_config_id");
|
||||
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<string>("LogLevel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("FullUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("full_url");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_delete_private");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_ignore_private");
|
||||
|
||||
b1.PrimitiveCollection<string>("IgnoredPatterns")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failed_import_ignored_patterns");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_delete_private");
|
||||
|
||||
b1.Property<string>("IgnoreAboveSize")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_ignore_above_size");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_max_strikes");
|
||||
|
||||
b1.Property<double>("MaxTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("slow_max_time");
|
||||
|
||||
b1.Property<string>("MinSpeed")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_min_speed");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_delete_private");
|
||||
|
||||
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_downloading_metadata_max_strikes");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_max_strikes");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_queue_cleaner_configs");
|
||||
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("DownloadCleanerConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
|
||||
|
||||
b.Navigation("DownloadCleanerConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKnownMalwareOption : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "delete_known_malware",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "delete_known_malware",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
public bool IgnorePrivate { get; set; }
|
||||
|
||||
public bool DeletePrivate { get; set; }
|
||||
|
||||
public bool DeleteKnownMalware { get; set; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; set; } = new();
|
||||
|
||||
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,28 @@ export const routes: Routes = [
|
||||
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
|
||||
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
|
||||
{ path: 'events', loadComponent: () => import('./events/events-viewer/events-viewer.component').then(m => m.EventsViewerComponent) },
|
||||
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent),
|
||||
path: 'general-settings',
|
||||
loadComponent: () => import('./settings/general-settings/general-settings.component').then(m => m.GeneralSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'queue-cleaner',
|
||||
loadComponent: () => import('./settings/queue-cleaner/queue-cleaner-settings.component').then(m => m.QueueCleanerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'content-blocker',
|
||||
loadComponent: () => import('./settings/content-blocker/content-blocker-settings.component').then(m => m.ContentBlockerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
{
|
||||
path: 'download-cleaner',
|
||||
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
|
||||
canDeactivate: [pendingChangesGuard]
|
||||
},
|
||||
|
||||
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
|
||||
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
|
||||
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },
|
||||
|
||||
@@ -71,6 +71,7 @@ export class DocumentationService {
|
||||
'jobSchedule.type': 'run-schedule',
|
||||
'ignorePrivate': 'ignore-private',
|
||||
'deletePrivate': 'delete-private',
|
||||
'deleteKnownMalware': 'delete-known-malware',
|
||||
'sonarr.enabled': 'enable-sonarr-blocklist',
|
||||
'sonarr.blocklistPath': 'sonarr-blocklist-path',
|
||||
'sonarr.blocklistType': 'sonarr-blocklist-type',
|
||||
@@ -84,7 +85,7 @@ export class DocumentationService {
|
||||
'download-client': {
|
||||
'enabled': 'enable-download-client',
|
||||
'name': 'client-name',
|
||||
'type': 'client-type',
|
||||
'typeName': 'client-type',
|
||||
'host': 'client-host',
|
||||
'urlBase': 'url-base-path',
|
||||
'username': 'username',
|
||||
|
||||
@@ -10,115 +10,105 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<nav class="nav-menu">
|
||||
<!-- Project Sponsors Link -->
|
||||
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
|
||||
<!-- Show loading skeleton while determining navigation state -->
|
||||
<nav class="nav-menu" *ngIf="!isNavigationReady">
|
||||
<div class="nav-skeleton">
|
||||
<div class="skeleton-item"
|
||||
*ngFor="let item of getSkeletonItems()"
|
||||
[class.sponsor-skeleton]="item.isSponsor">
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Show actual navigation when ready -->
|
||||
<nav class="nav-menu"
|
||||
*ngIf="isNavigationReady"
|
||||
[@staggerItems]>
|
||||
<!-- Project Sponsors Link (always visible) -->
|
||||
<a href="https://cleanuparr.github.io/Cleanuparr/support"
|
||||
class="nav-item sponsor-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper heart-icon">
|
||||
<i class="pi pi-heart"></i>
|
||||
</div>
|
||||
<span>Become A Sponsor</span>
|
||||
</a>
|
||||
|
||||
<a [routerLink]="['/dashboard']" class="nav-item" [class.active]="router.url.includes('/dashboard')" (click)="onNavItemClick()">
|
||||
<!-- Go Back button (shown when not at root level) -->
|
||||
<div class="nav-item go-back-button"
|
||||
*ngIf="canGoBack"
|
||||
(click)="goBack()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-home"></i>
|
||||
<i class="pi pi-arrow-left"></i>
|
||||
</div>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Settings Group -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Settings</div>
|
||||
<a [routerLink]="['/sonarr']" class="nav-item" [class.active]="router.url.includes('/sonarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-play-circle"></i>
|
||||
</div>
|
||||
<span>Sonarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/radarr']" class="nav-item" [class.active]="router.url.includes('/radarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-play-circle"></i>
|
||||
</div>
|
||||
<span>Radarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/lidarr']" class="nav-item" [class.active]="router.url.includes('/lidarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-bolt"></i>
|
||||
</div>
|
||||
<span>Lidarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-book"></i>
|
||||
</div>
|
||||
<span>Readarr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/whisparr']" class="nav-item" [class.active]="router.url.includes('/whisparr')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-lock"></i>
|
||||
</div>
|
||||
<span>Whisparr</span>
|
||||
</a>
|
||||
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-download"></i>
|
||||
</div>
|
||||
<span>Download Clients</span>
|
||||
</a>
|
||||
<a [routerLink]="['/settings']" class="nav-item" [class.active]="router.url.includes('/settings')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-trash"></i>
|
||||
</div>
|
||||
<span>Cleanup</span>
|
||||
</a>
|
||||
<a [routerLink]="['/notifications']" class="nav-item" [class.active]="router.url.includes('/notifications')" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-bell"></i>
|
||||
</div>
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
<span>Go Back</span>
|
||||
</div>
|
||||
|
||||
<!-- Activity Group -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Activity</div>
|
||||
<ng-container *ngFor="let item of menuItems">
|
||||
<ng-container *ngIf="!['Dashboard', 'Settings'].includes(item.label)">
|
||||
<a [routerLink]="item.route" class="nav-item" [class.active]="router.url.includes(item.route)" (click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<!-- Breadcrumb (optional, for better UX) -->
|
||||
<div class="breadcrumb"
|
||||
*ngIf="navigationBreadcrumb.length > 0">
|
||||
<span *ngFor="let crumb of navigationBreadcrumb; let last = last; trackBy: trackByBreadcrumb">
|
||||
{{ crumb.label }}
|
||||
<i class="pi pi-chevron-right" *ngIf="!last"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation items container with container-level animation -->
|
||||
<div class="navigation-items-container"
|
||||
[@navigationContainer]="navigationStateKey">
|
||||
<!-- Current level navigation items -->
|
||||
<ng-container *ngFor="let item of currentNavigation; trackBy: trackByItemId">
|
||||
<!-- Section headers for top-level sections -->
|
||||
<div
|
||||
class="nav-section-header"
|
||||
*ngIf="item.isHeader">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Items with children (drill-down) - exclude top-level items -->
|
||||
<div
|
||||
class="nav-item nav-parent"
|
||||
*ngIf="item.children && item.children.length > 0 && !item.topLevel"
|
||||
(click)="navigateToLevel(item)">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="nav-chevron">
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct navigation items -->
|
||||
<a
|
||||
[routerLink]="item.route"
|
||||
class="nav-item"
|
||||
*ngIf="!item.children && item.route && !item.isHeader"
|
||||
[class.active]="router.url.includes(item.route)"
|
||||
(click)="onNavItemClick()">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="nav-badge" *ngIf="item.badge">{{ item.badge }}</span>
|
||||
</a>
|
||||
|
||||
<!-- External links -->
|
||||
<a
|
||||
[href]="item.href"
|
||||
class="nav-item"
|
||||
*ngIf="!item.children && item.isExternal && !item.isHeader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i [class]="item.icon"></i>
|
||||
</div>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Resources Group -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Resources</div>
|
||||
<a href="https://github.com/Cleanuparr/Cleanuparr/issues" class="nav-item" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-github"></i>
|
||||
</div>
|
||||
<span>Issues and requests</span>
|
||||
</a>
|
||||
|
||||
<a href="https://discord.gg/SCtMCgtsc4" class="nav-item" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-discord"></i>
|
||||
</div>
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Recommended apps</div>
|
||||
<a href="https://github.com/plexguide/Huntarr.io" class="nav-item" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-icon-wrapper">
|
||||
<i class="pi pi-github"></i>
|
||||
</div>
|
||||
<span>Huntarr</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
// Main container stability
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden; // Prevent scrolling
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Logo container
|
||||
.logo-container {
|
||||
display: flex;
|
||||
@@ -50,7 +58,69 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
gap: 0; // Remove gap to prevent layout shifts
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
// Prevent horizontal scrolling
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
// Fixed minimum height to prevent jumping
|
||||
min-height: 400px;
|
||||
|
||||
// Navigation items container for smooth animations
|
||||
.navigation-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px; // Consistent spacing between navigation items
|
||||
position: relative; // Ensure proper stacking context for animations
|
||||
width: 100%; // Take full width of parent
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
.nav-skeleton {
|
||||
padding: 0;
|
||||
|
||||
.skeleton-item {
|
||||
height: 60px; // Match actual nav-item height
|
||||
padding: 10px 20px; // Match nav-item padding
|
||||
margin-bottom: 8px; // Match nav-item spacing
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--surface-200) 25%, var(--surface-300) 50%, var(--surface-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.sponsor-skeleton {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
// Add fake icon and text areas to match real content
|
||||
&::before {
|
||||
content: '';
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-300);
|
||||
margin-right: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
height: 20px;
|
||||
background: var(--surface-300);
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sponsor link
|
||||
.sponsor-link {
|
||||
@@ -67,20 +137,78 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nav groups
|
||||
.nav-group {
|
||||
|
||||
// Go back button styling
|
||||
.go-back-button {
|
||||
background-color: var(--surface-200);
|
||||
border: 1px solid var(--surface-300);
|
||||
margin-bottom: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.nav-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
padding: 0 20px 8px;
|
||||
margin: 5px 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
&:hover {
|
||||
transform: translateX(2px);
|
||||
background-color: var(--surface-300);
|
||||
|
||||
.nav-icon-wrapper i {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb styling
|
||||
.breadcrumb {
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
span {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
i {
|
||||
margin: 0 8px;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// Section headers for top-level sections
|
||||
.nav-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 20px 4px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 15px 0 8px 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
|
||||
.nav-icon-wrapper {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-200);
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +222,12 @@
|
||||
border-radius: 0 6px 6px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
margin-bottom: 8px; // Consistent spacing instead of gap
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-icon-wrapper {
|
||||
width: 40px;
|
||||
@@ -106,17 +239,33 @@
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
flex-shrink: 0; // Prevent icon from shrinking
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: var(--text-color-secondary);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1; // Take available space
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -131,7 +280,9 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(4px);
|
||||
background-color: var(--surface-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.nav-icon-wrapper {
|
||||
background-color: rgba(var(--primary-500-rgb), 0.1);
|
||||
@@ -161,6 +312,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parent navigation items (with children)
|
||||
.nav-parent {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
.nav-chevron {
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .nav-chevron i {
|
||||
transform: translateX(3px) scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading skeleton animation
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Component, Input, inject, Output, EventEmitter } from '@angular/core';
|
||||
import { Component, Input, inject, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
@@ -10,6 +13,24 @@ interface MenuItem {
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
route?: string; // For direct navigation items
|
||||
children?: NavigationItem[]; // For parent items with sub-menus
|
||||
isExternal?: boolean; // For external links
|
||||
href?: string; // For external URLs
|
||||
badge?: string; // For notification badges
|
||||
topLevel?: boolean; // If true, shows children directly on top level instead of drill-down
|
||||
isHeader?: boolean; // If true, renders as a section header (non-clickable)
|
||||
}
|
||||
|
||||
interface RouteMapping {
|
||||
route: string;
|
||||
navigationPath: string[]; // Array of navigation item IDs leading to this route
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar-content',
|
||||
standalone: true,
|
||||
@@ -19,9 +40,37 @@ interface MenuItem {
|
||||
ButtonModule
|
||||
],
|
||||
templateUrl: './sidebar-content.component.html',
|
||||
styleUrl: './sidebar-content.component.scss'
|
||||
styleUrl: './sidebar-content.component.scss',
|
||||
animations: [
|
||||
trigger('staggerItems', [
|
||||
transition(':enter', [
|
||||
query(':enter', [
|
||||
style({ transform: 'translateX(30px)', opacity: 0 }),
|
||||
stagger('50ms', [
|
||||
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)', style({ transform: 'translateX(0)', opacity: 1 }))
|
||||
])
|
||||
], { optional: true })
|
||||
])
|
||||
]),
|
||||
// Container-level navigation animation (replaces individual item animations)
|
||||
trigger('navigationContainer', [
|
||||
transition('* => *', [
|
||||
style({ transform: 'translateX(100%)', opacity: 0 }),
|
||||
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)',
|
||||
style({ transform: 'translateX(0)', opacity: 1 })
|
||||
)
|
||||
])
|
||||
]),
|
||||
// Simple fade in animation for initial load
|
||||
trigger('fadeIn', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0 }),
|
||||
animate('200ms ease-out', style({ opacity: 1 }))
|
||||
])
|
||||
])
|
||||
]
|
||||
})
|
||||
export class SidebarContentComponent {
|
||||
export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() menuItems: MenuItem[] = [];
|
||||
@Input() isMobile = false;
|
||||
@Output() navItemClicked = new EventEmitter<void>();
|
||||
@@ -29,6 +78,404 @@ export class SidebarContentComponent {
|
||||
// Inject router for active route styling
|
||||
public router = inject(Router);
|
||||
|
||||
// New properties for drill-down navigation
|
||||
navigationData: NavigationItem[] = [];
|
||||
currentNavigation: NavigationItem[] = [];
|
||||
navigationBreadcrumb: NavigationItem[] = [];
|
||||
canGoBack = false;
|
||||
|
||||
// Pre-rendering optimization properties
|
||||
isNavigationReady = false;
|
||||
private hasInitialized = false;
|
||||
|
||||
// Animation trigger property - changes to force re-render and trigger animations
|
||||
navigationStateKey = 0;
|
||||
|
||||
// Route synchronization properties
|
||||
private routerSubscription?: Subscription;
|
||||
private routeMappings: RouteMapping[] = [
|
||||
// Dashboard
|
||||
{ route: '/dashboard', navigationPath: ['dashboard'] },
|
||||
|
||||
// Media Management routes
|
||||
{ route: '/sonarr', navigationPath: ['media-apps', 'sonarr'] },
|
||||
{ route: '/radarr', navigationPath: ['media-apps', 'radarr'] },
|
||||
{ route: '/lidarr', navigationPath: ['media-apps', 'lidarr'] },
|
||||
{ route: '/readarr', navigationPath: ['media-apps', 'readarr'] },
|
||||
{ route: '/whisparr', navigationPath: ['media-apps', 'whisparr'] },
|
||||
{ route: '/download-clients', navigationPath: ['media-apps', 'download-clients'] },
|
||||
|
||||
// Settings routes
|
||||
{ route: '/general-settings', navigationPath: ['settings', 'general'] },
|
||||
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
|
||||
{ route: '/content-blocker', navigationPath: ['settings', 'content-blocker'] },
|
||||
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
|
||||
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
|
||||
|
||||
// Other routes will be handled dynamically
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
// Start with loading state
|
||||
this.isNavigationReady = false;
|
||||
|
||||
// Initialize navigation after showing skeleton
|
||||
setTimeout(() => {
|
||||
this.initializeNavigation();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['menuItems']) {
|
||||
this.updateActivityItems();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routerSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize navigation and determine correct level based on route
|
||||
*/
|
||||
private initializeNavigation(): void {
|
||||
if (this.hasInitialized) return;
|
||||
|
||||
// 1. Initialize navigation data
|
||||
this.setupNavigationData();
|
||||
|
||||
// 2. Update activity items if available
|
||||
if (this.menuItems && this.menuItems.length > 0) {
|
||||
this.updateActivityItems();
|
||||
}
|
||||
|
||||
// 3. Determine correct navigation level based on current route
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
|
||||
// 4. Mark as ready and subscribe to route changes
|
||||
this.isNavigationReady = true;
|
||||
this.hasInitialized = true;
|
||||
this.subscribeToRouteChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup basic navigation data structure
|
||||
*/
|
||||
private setupNavigationData(): void {
|
||||
this.navigationData = this.getNavigationData();
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build top-level navigation including expanded sections marked with topLevel: true
|
||||
*/
|
||||
private buildTopLevelNavigation(): NavigationItem[] {
|
||||
const topLevelItems: NavigationItem[] = [];
|
||||
|
||||
for (const item of this.navigationData) {
|
||||
if (item.topLevel && item.children) {
|
||||
// Add section header
|
||||
topLevelItems.push({
|
||||
id: `${item.id}-header`,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
isHeader: true
|
||||
});
|
||||
|
||||
// Add all children directly to top level
|
||||
topLevelItems.push(...item.children);
|
||||
} else {
|
||||
// Add item normally (drill-down behavior)
|
||||
topLevelItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return topLevelItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the navigation data structure
|
||||
*/
|
||||
private getNavigationData(): NavigationItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-home',
|
||||
route: '/dashboard'
|
||||
},
|
||||
{
|
||||
id: 'media-apps',
|
||||
label: 'Media Apps',
|
||||
icon: 'pi pi-play-circle',
|
||||
children: [
|
||||
{ id: 'sonarr', label: 'Sonarr', icon: 'pi pi-play-circle', route: '/sonarr' },
|
||||
{ id: 'radarr', label: 'Radarr', icon: 'pi pi-play-circle', route: '/radarr' },
|
||||
{ id: 'lidarr', label: 'Lidarr', icon: 'pi pi-bolt', route: '/lidarr' },
|
||||
{ id: 'readarr', label: 'Readarr', icon: 'pi pi-book', route: '/readarr' },
|
||||
{ id: 'whisparr', label: 'Whisparr', icon: 'pi pi-lock', route: '/whisparr' },
|
||||
{ id: 'download-clients', label: 'Download Clients', icon: 'pi pi-download', route: '/download-clients' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-cog',
|
||||
children: [
|
||||
{ id: 'general', label: 'General', icon: 'pi pi-cog', route: '/general-settings' },
|
||||
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
|
||||
{ id: 'content-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/content-blocker' },
|
||||
{ id: 'download-cleaner', label: 'Download Cleaner', icon: 'pi pi-trash', route: '/download-cleaner' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
icon: 'pi pi-chart-line',
|
||||
children: [] // Will be populated dynamically from menuItems
|
||||
},
|
||||
{
|
||||
id: 'help-support',
|
||||
label: 'Help & Support',
|
||||
icon: 'pi pi-question-circle',
|
||||
children: [
|
||||
{
|
||||
id: 'issues',
|
||||
label: 'Issues and Requests',
|
||||
icon: 'pi pi-github',
|
||||
isExternal: true,
|
||||
href: 'https://github.com/Cleanuparr/Cleanuparr/issues'
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
label: 'Discord',
|
||||
icon: 'pi pi-discord',
|
||||
isExternal: true,
|
||||
href: 'https://discord.gg/SCtMCgtsc4'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'suggested-apps',
|
||||
label: 'Suggested Apps',
|
||||
topLevel: true,
|
||||
icon: 'pi pi-star',
|
||||
children: [
|
||||
{
|
||||
id: 'huntarr',
|
||||
label: 'Huntarr',
|
||||
icon: 'pi pi-github',
|
||||
isExternal: true,
|
||||
href: 'https://github.com/plexguide/Huntarr.io'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to route mapping synchronously without delays
|
||||
*/
|
||||
private navigateToRouteMappingSync(mapping: RouteMapping): void {
|
||||
// No delays, no async operations - just set the state
|
||||
this.navigationBreadcrumb = [];
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
|
||||
for (let i = 0; i < mapping.navigationPath.length - 1; i++) {
|
||||
const itemId = mapping.navigationPath[i];
|
||||
// Find in original navigation data, not the flattened version
|
||||
const item = this.navigationData.find(nav => nav.id === itemId);
|
||||
|
||||
if (item && item.children && !item.topLevel) {
|
||||
// Only drill down if it's not a top-level section
|
||||
this.navigationBreadcrumb.push(item);
|
||||
this.currentNavigation = [...item.children];
|
||||
}
|
||||
}
|
||||
|
||||
this.updateNavigationState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skeleton items based on predicted navigation state
|
||||
*/
|
||||
getSkeletonItems(): Array<{isSponsor: boolean}> {
|
||||
const currentRoute = this.router.url;
|
||||
const mapping = this.findRouteMapping(currentRoute);
|
||||
|
||||
if (mapping && mapping.navigationPath.length > 1) {
|
||||
// We'll show sub-navigation, predict item count
|
||||
return [
|
||||
{ isSponsor: true },
|
||||
{ isSponsor: false }, // Go back
|
||||
...Array(6).fill({ isSponsor: false }) // Estimated items
|
||||
];
|
||||
}
|
||||
|
||||
// Default main navigation count
|
||||
return [
|
||||
{ isSponsor: true },
|
||||
...Array(5).fill({ isSponsor: false })
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackBy function for better performance
|
||||
*/
|
||||
trackByItemId(index: number, item: NavigationItem): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackBy function that includes navigation state for animation triggers
|
||||
*/
|
||||
trackByItemIdWithState(index: number, item: NavigationItem): string {
|
||||
return `${item.id}-${this.navigationStateKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrackBy function for breadcrumb items
|
||||
*/
|
||||
trackByBreadcrumb(index: number, item: NavigationItem): string {
|
||||
return `${item.id}-${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update activity items from menuItems input
|
||||
*/
|
||||
private updateActivityItems(): void {
|
||||
const activityItem = this.navigationData.find(item => item.id === 'activity');
|
||||
if (activityItem && this.menuItems) {
|
||||
activityItem.children = this.menuItems
|
||||
.filter(item => !['Dashboard', 'Settings'].includes(item.label))
|
||||
.map(item => ({
|
||||
id: item.label.toLowerCase().replace(/\s+/g, '-'),
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
route: item.route,
|
||||
badge: item.badge
|
||||
}));
|
||||
|
||||
// Update route mappings for activity items
|
||||
this.updateActivityRouteMappings();
|
||||
|
||||
// Update current navigation if we're showing the root level
|
||||
if (this.navigationBreadcrumb.length === 0) {
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
}
|
||||
|
||||
// Re-sync with current route to handle activity routes
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route mappings for activity items
|
||||
*/
|
||||
private updateActivityRouteMappings(): void {
|
||||
// Remove old activity mappings
|
||||
this.routeMappings = this.routeMappings.filter(mapping =>
|
||||
!mapping.navigationPath[0] || !mapping.navigationPath[0].startsWith('activity')
|
||||
);
|
||||
|
||||
// Add new activity mappings
|
||||
const activityItem = this.navigationData.find(item => item.id === 'activity');
|
||||
if (activityItem?.children) {
|
||||
activityItem.children.forEach(child => {
|
||||
if (child.route) {
|
||||
this.routeMappings.push({
|
||||
route: child.route,
|
||||
navigationPath: ['activity', child.id]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync sidebar state with current route
|
||||
*/
|
||||
private syncSidebarWithCurrentRoute(): void {
|
||||
const currentRoute = this.router.url;
|
||||
const mapping = this.findRouteMapping(currentRoute);
|
||||
|
||||
if (mapping) {
|
||||
this.navigateToRouteMapping(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find route mapping for current route
|
||||
*/
|
||||
private findRouteMapping(route: string): RouteMapping | null {
|
||||
// Find exact match first, or routes that start with the mapping route
|
||||
const mapping = this.routeMappings.find(m =>
|
||||
route === m.route || route.startsWith(m.route + '/')
|
||||
);
|
||||
|
||||
return mapping || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate sidebar to match route mapping (used by route sync)
|
||||
*/
|
||||
private navigateToRouteMapping(mapping: RouteMapping): void {
|
||||
// Use the synchronous version
|
||||
this.navigateToRouteMappingSync(mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to route changes for real-time synchronization
|
||||
*/
|
||||
private subscribeToRouteChanges(): void {
|
||||
this.routerSubscription = this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
this.syncSidebarWithCurrentRoute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a sub-level with animation trigger
|
||||
*/
|
||||
navigateToLevel(item: NavigationItem): void {
|
||||
if (item.children && item.children.length > 0) {
|
||||
this.navigationBreadcrumb.push(item);
|
||||
this.currentNavigation = item.children ? [...item.children] : [];
|
||||
this.navigationStateKey++; // Force animation trigger
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the previous level with animation trigger
|
||||
*/
|
||||
goBack(): void {
|
||||
if (this.navigationBreadcrumb.length > 0) {
|
||||
this.navigationBreadcrumb.pop();
|
||||
|
||||
if (this.navigationBreadcrumb.length === 0) {
|
||||
// Back to root level - use top-level navigation
|
||||
this.currentNavigation = this.buildTopLevelNavigation();
|
||||
} else {
|
||||
// Back to parent level
|
||||
const parent = this.navigationBreadcrumb[this.navigationBreadcrumb.length - 1];
|
||||
this.currentNavigation = parent.children ? [...parent.children] : [];
|
||||
}
|
||||
|
||||
this.navigationStateKey++; // Force animation trigger
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update navigation state
|
||||
*/
|
||||
private updateNavigationState(): void {
|
||||
this.canGoBack = this.navigationBreadcrumb.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation item click
|
||||
*/
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Malware Blocker</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Content Blocker Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Malware Blocker Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,21 +28,21 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Content Blocker
|
||||
Enable Malware Blocker
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, the content blocker will run according to the schedule</small>
|
||||
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduling Mode Toggle -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('useAdvancedScheduling')"
|
||||
title="Click for documentation"></i>
|
||||
Scheduling Mode
|
||||
@@ -92,7 +95,7 @@
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
@@ -109,27 +112,40 @@
|
||||
<!-- Content Blocker Specific Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be processed</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('deleteKnownMalware')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Known Malware
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deleteKnownMalware" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, downloads matching known malware patterns will be deleted</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +167,7 @@
|
||||
<div formGroupName="sonarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('sonarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Sonarr Blocklist
|
||||
@@ -164,7 +180,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('sonarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -180,7 +196,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('sonarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -221,7 +237,7 @@
|
||||
<div formGroupName="radarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('radarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Radarr Blocklist
|
||||
@@ -234,7 +250,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('radarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -250,7 +266,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('radarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -291,7 +307,7 @@
|
||||
<div formGroupName="lidarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('lidarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Lidarr Blocklist
|
||||
@@ -304,7 +320,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('lidarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -320,7 +336,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('lidarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -361,7 +377,7 @@
|
||||
<div formGroupName="readarr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Readarr Blocklist
|
||||
@@ -374,7 +390,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -390,7 +406,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('readarr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -431,7 +447,7 @@
|
||||
<div formGroupName="whisparr">
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Whisparr Blocklist
|
||||
@@ -444,7 +460,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.blocklistPath')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
@@ -460,7 +476,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('whisparr.blocklistType')"
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Type
|
||||
@@ -509,4 +525,5 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</p-card>
|
||||
</p-card>
|
||||
</div>
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Content Blocker Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -132,6 +132,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
|
||||
ignorePrivate: [{ value: false, disabled: true }],
|
||||
deletePrivate: [{ value: false, disabled: true }],
|
||||
deleteKnownMalware: [{ value: false, disabled: true }],
|
||||
|
||||
// Blocklist settings for each Arr
|
||||
sonarr: this.formBuilder.group({
|
||||
@@ -165,26 +166,36 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
effect(() => {
|
||||
const config = this.contentBlockerConfig();
|
||||
if (config) {
|
||||
// Reset form with the config values
|
||||
// Handle the case where ignorePrivate is true but deletePrivate is also true
|
||||
// This shouldn't happen, but if it does, correct it
|
||||
const correctedConfig = { ...config };
|
||||
|
||||
// For Content Blocker
|
||||
if (correctedConfig.ignorePrivate && correctedConfig.deletePrivate) {
|
||||
correctedConfig.deletePrivate = false;
|
||||
}
|
||||
|
||||
// Reset form with the corrected config values
|
||||
this.contentBlockerForm.patchValue({
|
||||
enabled: config.enabled,
|
||||
useAdvancedScheduling: config.useAdvancedScheduling || false,
|
||||
cronExpression: config.cronExpression,
|
||||
jobSchedule: config.jobSchedule || {
|
||||
enabled: correctedConfig.enabled,
|
||||
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
|
||||
cronExpression: correctedConfig.cronExpression,
|
||||
jobSchedule: correctedConfig.jobSchedule || {
|
||||
every: 5,
|
||||
type: ScheduleUnit.Seconds
|
||||
},
|
||||
ignorePrivate: config.ignorePrivate,
|
||||
deletePrivate: config.deletePrivate,
|
||||
sonarr: config.sonarr,
|
||||
radarr: config.radarr,
|
||||
lidarr: config.lidarr,
|
||||
readarr: config.readarr,
|
||||
whisparr: config.whisparr,
|
||||
ignorePrivate: correctedConfig.ignorePrivate,
|
||||
deletePrivate: correctedConfig.deletePrivate,
|
||||
deleteKnownMalware: correctedConfig.deleteKnownMalware,
|
||||
sonarr: correctedConfig.sonarr,
|
||||
radarr: correctedConfig.radarr,
|
||||
lidarr: correctedConfig.lidarr,
|
||||
readarr: correctedConfig.readarr,
|
||||
whisparr: correctedConfig.whisparr,
|
||||
});
|
||||
|
||||
// Update all form control states
|
||||
this.updateFormControlDisabledStates(config);
|
||||
this.updateFormControlDisabledStates(correctedConfig);
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
@@ -245,6 +256,27 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.updateMainControlsState(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
// Add listener for ignorePrivate changes
|
||||
const ignorePrivateControl = this.contentBlockerForm.get('ignorePrivate');
|
||||
if (ignorePrivateControl) {
|
||||
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((ignorePrivate: boolean) => {
|
||||
const deletePrivateControl = this.contentBlockerForm.get('deletePrivate');
|
||||
|
||||
if (ignorePrivate && deletePrivateControl) {
|
||||
// If ignoring private, uncheck and disable delete private
|
||||
deletePrivateControl.setValue(false);
|
||||
deletePrivateControl.disable({ onlySelf: true });
|
||||
} else if (!ignorePrivate && deletePrivateControl) {
|
||||
// If not ignoring private, enable delete private (if main feature is enabled)
|
||||
const mainEnabled = this.contentBlockerForm.get('enabled')?.value || false;
|
||||
if (mainEnabled) {
|
||||
deletePrivateControl.enable({ onlySelf: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the 'useAdvancedScheduling' control
|
||||
const advancedControl = this.contentBlockerForm.get('useAdvancedScheduling');
|
||||
@@ -415,7 +447,17 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
|
||||
// Enable content blocker specific controls
|
||||
this.contentBlockerForm.get("ignorePrivate")?.enable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("deletePrivate")?.enable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("deleteKnownMalware")?.enable({ onlySelf: true });
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.contentBlockerForm.get("ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.contentBlockerForm.get("deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable({ onlySelf: true });
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable({ onlySelf: true });
|
||||
}
|
||||
|
||||
// Enable blocklist settings for each Arr
|
||||
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
|
||||
@@ -449,6 +491,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
// Disable content blocker specific controls
|
||||
this.contentBlockerForm.get("ignorePrivate")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("deletePrivate")?.disable({ onlySelf: true });
|
||||
this.contentBlockerForm.get("deleteKnownMalware")?.disable({ onlySelf: true });
|
||||
|
||||
// Disable all blocklist settings for each Arr
|
||||
this.contentBlockerForm.get("sonarr.enabled")?.disable({ onlySelf: true });
|
||||
@@ -494,6 +537,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
jobSchedule: formValue.jobSchedule,
|
||||
ignorePrivate: formValue.ignorePrivate || false,
|
||||
deletePrivate: formValue.deletePrivate || false,
|
||||
deleteKnownMalware: formValue.deleteKnownMalware || false,
|
||||
sonarr: formValue.sonarr || {
|
||||
enabled: false,
|
||||
blocklistPath: "",
|
||||
@@ -572,6 +616,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
},
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
deleteKnownMalware: false,
|
||||
sonarr: {
|
||||
enabled: false,
|
||||
blocklistPath: "",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Download Cleaner</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic download cleanup</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic download cleanup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,7 +28,7 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Download Cleaner
|
||||
@@ -39,7 +42,7 @@
|
||||
<!-- Scheduling Mode Toggle -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('useAdvancedScheduling')"
|
||||
title="Click for documentation"></i>
|
||||
Scheduling Mode
|
||||
@@ -92,7 +95,7 @@
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="downloadCleanerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
@@ -124,7 +127,7 @@
|
||||
<!-- Delete Private Option -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private Torrents
|
||||
@@ -155,7 +158,7 @@
|
||||
<div class="category-title">
|
||||
<i class="pi pi-tag category-icon"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('name')"
|
||||
title="Click for documentation"></i>
|
||||
</div>
|
||||
@@ -167,7 +170,7 @@
|
||||
<div class="category-content">
|
||||
<div class="category-field">
|
||||
<label>
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('maxRatio')"
|
||||
title="Click for documentation"></i>
|
||||
Max Ratio
|
||||
@@ -185,7 +188,7 @@
|
||||
|
||||
<div class="category-field">
|
||||
<label>
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('minSeedTime')"
|
||||
title="Click for documentation"></i>
|
||||
Min Seed Time (hours)
|
||||
@@ -202,7 +205,7 @@
|
||||
|
||||
<div class="category-field">
|
||||
<label>
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('maxSeedTime')"
|
||||
title="Click for documentation"></i>
|
||||
Max Seed Time (hours)
|
||||
@@ -249,7 +252,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedEnabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Unlinked Download Handling
|
||||
@@ -263,7 +266,7 @@
|
||||
<!-- Unlinked Target Category -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedTargetCategory')"
|
||||
title="Click for documentation"></i>
|
||||
Target Category
|
||||
@@ -274,13 +277,14 @@
|
||||
</div>
|
||||
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="p-error">Target category is required</small>
|
||||
<small class="form-helper-text">Category to move unlinked downloads to</small>
|
||||
<small class="form-helper-text">You have to create a seeding rule for this category if you want to remove the downloads</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Use Tag Option -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedUseTag')"
|
||||
title="Click for documentation"></i>
|
||||
Use Tag
|
||||
@@ -294,7 +298,7 @@
|
||||
<!-- Ignored Root Directory -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedIgnoredRootDir')"
|
||||
title="Click for documentation"></i>
|
||||
Ignored Root Directory
|
||||
@@ -310,7 +314,7 @@
|
||||
<!-- Unlinked Categories -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedCategories')"
|
||||
title="Click for documentation"></i>
|
||||
Unlinked Categories
|
||||
@@ -370,8 +374,9 @@
|
||||
</p-card>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Download Cleaner Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,28 +146,28 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="client-type">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
(click)="openFieldDocs('type')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('typeName')"
|
||||
pTooltip="Click for documentation"></i>
|
||||
Client Type *
|
||||
</label>
|
||||
<p-select
|
||||
id="client-type"
|
||||
formControlName="type"
|
||||
[options]="clientTypeOptions"
|
||||
formControlName="typeName"
|
||||
[options]="typeNameOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select client type"
|
||||
appendTo="body"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(clientForm, 'type', 'required')" class="p-error">Client type is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="p-error">Client type is required</small>
|
||||
</div>
|
||||
|
||||
<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
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
|
||||
import { DownloadClientConfigStore } from "./download-client-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
|
||||
import { DownloadClientType } from "../../shared/models/enums";
|
||||
import { DownloadClientType, DownloadClientTypeName } from "../../shared/models/enums";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
|
||||
// PrimeNG Components
|
||||
@@ -56,11 +56,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
editingClient: ClientConfig | null = null;
|
||||
|
||||
// Download client type options
|
||||
clientTypeOptions = [
|
||||
{ label: "qBittorrent", value: DownloadClientType.QBittorrent },
|
||||
{ label: "Deluge", value: DownloadClientType.Deluge },
|
||||
{ label: "Transmission", value: DownloadClientType.Transmission },
|
||||
];
|
||||
typeNameOptions: { label: string, value: DownloadClientTypeName }[] = [];
|
||||
|
||||
// Clean up subscriptions
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -89,7 +85,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
// Initialize client form for modal
|
||||
this.clientForm = this.formBuilder.group({
|
||||
name: ['', Validators.required],
|
||||
type: [null, Validators.required],
|
||||
typeName: [null, Validators.required],
|
||||
host: ['', [Validators.required, this.uriValidator.bind(this)]],
|
||||
username: [''],
|
||||
password: [''],
|
||||
@@ -97,11 +93,19 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
enabled: [true]
|
||||
});
|
||||
|
||||
// Initialize type name options
|
||||
for (const key of Object.keys(DownloadClientTypeName)) {
|
||||
this.typeNameOptions.push({
|
||||
label: key,
|
||||
value: DownloadClientTypeName[key as keyof typeof DownloadClientTypeName]
|
||||
});
|
||||
}
|
||||
|
||||
// Load Download Client config data
|
||||
this.downloadClientStore.loadConfig();
|
||||
|
||||
// Setup client type change handler
|
||||
this.clientForm.get('type')?.valueChanges
|
||||
this.clientForm.get('typeName')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.onClientTypeChange();
|
||||
@@ -184,14 +188,9 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.modalMode = 'edit';
|
||||
this.editingClient = client;
|
||||
|
||||
// Map backend type to frontend type
|
||||
const frontendType = client.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client.type;
|
||||
|
||||
this.clientForm.patchValue({
|
||||
name: client.name,
|
||||
type: frontendType,
|
||||
typeName: client.typeName,
|
||||
host: client.host,
|
||||
username: client.username,
|
||||
password: client.password,
|
||||
@@ -222,28 +221,27 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
const formValue = this.clientForm.value;
|
||||
const mappedType = this.mapClientTypeForBackend(formValue.type);
|
||||
|
||||
const clientData: CreateDownloadClientDto = {
|
||||
name: formValue.name,
|
||||
typeName: mappedType.typeName,
|
||||
type: mappedType.type,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
urlBase: formValue.urlBase,
|
||||
enabled: formValue.enabled
|
||||
};
|
||||
|
||||
if (this.modalMode === 'add') {
|
||||
const clientData: CreateDownloadClientDto = {
|
||||
name: formValue.name,
|
||||
type: this.mapTypeNameToType(formValue.typeName),
|
||||
typeName: formValue.typeName,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
urlBase: formValue.urlBase,
|
||||
enabled: formValue.enabled
|
||||
};
|
||||
|
||||
this.downloadClientStore.createClient(clientData);
|
||||
} else if (this.editingClient) {
|
||||
// For updates, create a proper ClientConfig object
|
||||
const clientConfig: ClientConfig = {
|
||||
id: this.editingClient.id!,
|
||||
id: this.editingClient.id,
|
||||
name: formValue.name,
|
||||
type: formValue.type, // Keep the frontend enum type
|
||||
typeName: mappedType.typeName,
|
||||
type: this.mapTypeNameToType(formValue.typeName),
|
||||
typeName: formValue.typeName,
|
||||
host: formValue.host,
|
||||
username: formValue.username,
|
||||
password: formValue.password,
|
||||
@@ -325,42 +323,25 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
}
|
||||
|
||||
/**
|
||||
* Map frontend client type to backend TypeName and Type
|
||||
* Map typeName to type category
|
||||
*/
|
||||
private mapClientTypeForBackend(frontendType: DownloadClientType): { typeName: string, type: string } {
|
||||
switch (frontendType) {
|
||||
case DownloadClientType.QBittorrent:
|
||||
return { typeName: 'qBittorrent', type: 'Torrent' };
|
||||
case DownloadClientType.Deluge:
|
||||
return { typeName: 'Deluge', type: 'Torrent' };
|
||||
case DownloadClientType.Transmission:
|
||||
return { typeName: 'Transmission', type: 'Torrent' };
|
||||
private mapTypeNameToType(typeName: DownloadClientTypeName): DownloadClientType {
|
||||
switch (typeName) {
|
||||
case DownloadClientTypeName.qBittorrent:
|
||||
case DownloadClientTypeName.Deluge:
|
||||
case DownloadClientTypeName.Transmission:
|
||||
case DownloadClientTypeName.uTorrent:
|
||||
return DownloadClientType.Torrent;
|
||||
default:
|
||||
return { typeName: 'QBittorrent', type: 'Torrent' };
|
||||
throw new Error(`Unknown client type name: ${typeName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend TypeName to frontend client type
|
||||
*/
|
||||
private mapClientTypeFromBackend(backendTypeName: string): DownloadClientType {
|
||||
switch (backendTypeName) {
|
||||
case 'QBittorrent':
|
||||
return DownloadClientType.QBittorrent;
|
||||
case 'Deluge':
|
||||
return DownloadClientType.Deluge;
|
||||
case 'Transmission':
|
||||
return DownloadClientType.Transmission;
|
||||
default:
|
||||
return DownloadClientType.QBittorrent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client type changes to update validation
|
||||
*/
|
||||
onClientTypeChange(): void {
|
||||
const clientType = this.clientForm.get('type')?.value;
|
||||
const clientTypeName = this.clientForm.get('typeName')?.value;
|
||||
const hostControl = this.clientForm.get('host');
|
||||
const usernameControl = this.clientForm.get('username');
|
||||
const urlBaseControl = this.clientForm.get('urlBase');
|
||||
@@ -373,13 +354,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
]);
|
||||
|
||||
// Clear username value and remove validation for Deluge
|
||||
if (clientType === DownloadClientType.Deluge) {
|
||||
if (clientTypeName === DownloadClientTypeName.Deluge) {
|
||||
usernameControl.setValue('');
|
||||
usernameControl.clearValidators();
|
||||
}
|
||||
|
||||
// Set default URL base for Transmission
|
||||
if (clientType === DownloadClientType.Transmission) {
|
||||
if (clientTypeName === DownloadClientTypeName.Transmission) {
|
||||
urlBaseControl.setValue('transmission');
|
||||
}
|
||||
|
||||
@@ -392,19 +373,15 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
|
||||
* Check if username field should be shown (hidden for Deluge)
|
||||
*/
|
||||
shouldShowUsernameField(): boolean {
|
||||
const clientType = this.clientForm.get('type')?.value;
|
||||
return clientType !== DownloadClientType.Deluge;
|
||||
const clientTypeName = this.clientForm.get('typeName')?.value;
|
||||
return clientTypeName !== DownloadClientTypeName.Deluge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client type label for display
|
||||
*/
|
||||
getClientTypeLabel(client: ClientConfig): string {
|
||||
const frontendType = client.typeName
|
||||
? this.mapClientTypeFromBackend(client.typeName)
|
||||
: client.type;
|
||||
|
||||
const option = this.clientTypeOptions.find(opt => opt.value === frontendType);
|
||||
const option = this.typeNameOptions.find(opt => opt.value === client.typeName);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>General Settings</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">General Configuration</h2>
|
||||
<span class="card-subtitle">Configure general application settings</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">General Configuration</h2>
|
||||
<span class="card-subtitle">Configure general application settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,7 +28,7 @@
|
||||
<!-- Display Support Banner -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('displaySupportBanner')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -40,7 +43,7 @@
|
||||
<!-- Dry Run -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('dryRun')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -55,7 +58,7 @@
|
||||
<!-- HTTP Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('httpMaxRetries')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -79,7 +82,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('httpTimeout')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -103,7 +106,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('httpCertificateValidation')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -124,7 +127,7 @@
|
||||
<!-- Search Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('searchEnabled')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -138,7 +141,7 @@
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('searchDelay')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -163,7 +166,7 @@
|
||||
<!-- Log Level -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('logLevel')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -184,7 +187,7 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -241,3 +244,4 @@
|
||||
[style]="{ width: '500px', maxWidth: '90vw' }"
|
||||
[baseZIndex]="10000">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* General Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- API Key -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('notifiarr.apiKey')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -41,14 +41,14 @@
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
|
||||
<small class="form-helper-text">Your Notifiarr API key for authentication</small>
|
||||
<small class="form-helper-text">Passthrough integration must be enabled and a Passthrough key needs to be created in your profile</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel ID -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('notifiarr.channelId')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -56,15 +56,21 @@
|
||||
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="channelId" inputId="notifiarrChannelId" placeholder="Enter channel ID" />
|
||||
<small class="form-helper-text">The channel ID where notifications will be sent</small>
|
||||
<p-inputNumber
|
||||
placeholder="Enter channel ID"
|
||||
formControlName="channelId"
|
||||
[min]="0"
|
||||
[useGrouping]="false"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<small class="form-helper-text">The Discord channel ID where notifications will be sent</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Triggers -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('eventTriggers')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -110,7 +116,7 @@
|
||||
<!-- URL -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.fullUrl')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -125,7 +131,7 @@
|
||||
<!-- Key -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.key')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -140,7 +146,7 @@
|
||||
<!-- Tags -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('apprise.tags')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
@@ -155,7 +161,7 @@
|
||||
<!-- Event Triggers -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('eventTriggers')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NotificationsConfig } from "../../shared/models/notifications-config.mo
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
import { InputTextModule } from "primeng/inputtext";
|
||||
import { InputNumberModule } from "primeng/inputnumber";
|
||||
import { CheckboxModule } from "primeng/checkbox";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
@@ -24,6 +25,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
|
||||
ReactiveFormsModule,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
InputNumberModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
ToastModule,
|
||||
@@ -221,7 +223,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
const formValues = this.notificationForm.value;
|
||||
|
||||
const config: NotificationsConfig = {
|
||||
notifiarr: formValues.notifiarr,
|
||||
notifiarr: {
|
||||
...formValues.notifiarr,
|
||||
channelId: formValues.notifiarr.channelId ? formValues.notifiarr.channelId.toString() : null,
|
||||
},
|
||||
apprise: formValues.apprise,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!-- Toast notifications handled by central toast container -->
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Queue Cleaner</h1>
|
||||
</div>
|
||||
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
|
||||
<p-card styleClass="settings-card h-full">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
@@ -25,7 +28,7 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Queue Cleaner
|
||||
@@ -39,7 +42,7 @@
|
||||
<!-- Scheduling Mode Toggle -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('useAdvancedScheduling')"
|
||||
title="Click for documentation"></i>
|
||||
Scheduling Mode
|
||||
@@ -92,7 +95,7 @@
|
||||
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
|
||||
<div class="field-row" *ngIf="queueCleanerForm.get('useAdvancedScheduling')?.value">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('cronExpression')"
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
@@ -123,7 +126,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.maxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
@@ -148,33 +151,33 @@
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be checked for being failed imports</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.ignoredPatterns')"
|
||||
title="Click for documentation"></i>
|
||||
Ignored Patterns
|
||||
@@ -219,7 +222,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.maxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
@@ -244,7 +247,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
|
||||
title="Click for documentation"></i>
|
||||
Reset Strikes On Progress
|
||||
@@ -257,27 +260,27 @@
|
||||
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be checked for being stalled</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
@@ -298,7 +301,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="stalled">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes for Downloading Metadata
|
||||
@@ -338,7 +341,7 @@
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.maxStrikes')"
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
@@ -363,7 +366,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
|
||||
title="Click for documentation"></i>
|
||||
Reset Strikes On Progress
|
||||
@@ -376,33 +379,33 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.ignorePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="ignorePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be skipped</small>
|
||||
<small class="form-helper-text">When enabled, private torrents will not be checked for being slow</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.deletePrivate')"
|
||||
title="Click for documentation"></i>
|
||||
Delete Private
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="deletePrivate" [binary]="true"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, private torrents will be deleted</small>
|
||||
<small class="form-helper-text">Disable this if you want to keep private torrents in the download client even if they are removed from the arrs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.minSpeed')"
|
||||
title="Click for documentation"></i>
|
||||
Minimum Speed
|
||||
@@ -413,6 +416,7 @@
|
||||
[min]="0"
|
||||
placeholder="Enter minimum speed"
|
||||
helpText="Minimum speed threshold for slow downloads (e.g., 100KB/s)"
|
||||
type="speed"
|
||||
>
|
||||
</app-byte-size-input>
|
||||
</div>
|
||||
@@ -420,7 +424,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.maxTime')"
|
||||
title="Click for documentation"></i>
|
||||
Maximum Time (hours)
|
||||
@@ -443,7 +447,7 @@
|
||||
|
||||
<div class="field-row" formGroupName="slow">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-info-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('slow.ignoreAboveSize')"
|
||||
title="Click for documentation"></i>
|
||||
Ignore Above Size
|
||||
@@ -454,6 +458,7 @@
|
||||
[min]="0"
|
||||
placeholder="Enter size threshold"
|
||||
helpText="Downloads will be ignored if size exceeds, e.g., 25 GB"
|
||||
type="size"
|
||||
>
|
||||
</app-byte-size-input>
|
||||
</div>
|
||||
@@ -488,3 +493,4 @@
|
||||
</form>
|
||||
</div>
|
||||
</p-card>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Queue Cleaner Settings Styles */
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -176,25 +176,40 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
effect(() => {
|
||||
const config = this.queueCleanerConfig();
|
||||
if (config) {
|
||||
// Save original cron expression
|
||||
const cronExpression = config.cronExpression;
|
||||
// Handle the case where ignorePrivate is true but deletePrivate is also true
|
||||
// This shouldn't happen, but if it does, correct it
|
||||
const correctedConfig = { ...config };
|
||||
|
||||
// Reset form with the config values
|
||||
// For Queue Cleaner (apply to all sections)
|
||||
if (correctedConfig.failedImport?.ignorePrivate && correctedConfig.failedImport?.deletePrivate) {
|
||||
correctedConfig.failedImport.deletePrivate = false;
|
||||
}
|
||||
if (correctedConfig.stalled?.ignorePrivate && correctedConfig.stalled?.deletePrivate) {
|
||||
correctedConfig.stalled.deletePrivate = false;
|
||||
}
|
||||
if (correctedConfig.slow?.ignorePrivate && correctedConfig.slow?.deletePrivate) {
|
||||
correctedConfig.slow.deletePrivate = false;
|
||||
}
|
||||
|
||||
// Save original cron expression
|
||||
const cronExpression = correctedConfig.cronExpression;
|
||||
|
||||
// Reset form with the corrected config values
|
||||
this.queueCleanerForm.patchValue({
|
||||
enabled: config.enabled,
|
||||
useAdvancedScheduling: config.useAdvancedScheduling || false,
|
||||
cronExpression: config.cronExpression,
|
||||
jobSchedule: config.jobSchedule || {
|
||||
enabled: correctedConfig.enabled,
|
||||
useAdvancedScheduling: correctedConfig.useAdvancedScheduling || false,
|
||||
cronExpression: correctedConfig.cronExpression,
|
||||
jobSchedule: correctedConfig.jobSchedule || {
|
||||
every: 5,
|
||||
type: ScheduleUnit.Minutes
|
||||
},
|
||||
failedImport: config.failedImport,
|
||||
stalled: config.stalled,
|
||||
slow: config.slow,
|
||||
failedImport: correctedConfig.failedImport,
|
||||
stalled: correctedConfig.stalled,
|
||||
slow: correctedConfig.slow,
|
||||
});
|
||||
|
||||
// Then update all other dependent form control states
|
||||
this.updateFormControlDisabledStates(config);
|
||||
this.updateFormControlDisabledStates(correctedConfig);
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
@@ -255,6 +270,30 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.updateMainControlsState(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
// Add listeners for ignorePrivate changes in each section
|
||||
['failedImport', 'stalled', 'slow'].forEach(section => {
|
||||
const ignorePrivateControl = this.queueCleanerForm.get(`${section}.ignorePrivate`);
|
||||
|
||||
if (ignorePrivateControl) {
|
||||
ignorePrivateControl.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((ignorePrivate: boolean) => {
|
||||
const deletePrivateControl = this.queueCleanerForm.get(`${section}.deletePrivate`);
|
||||
|
||||
if (ignorePrivate && deletePrivateControl) {
|
||||
// If ignoring private, uncheck and disable delete private
|
||||
deletePrivateControl.setValue(false);
|
||||
deletePrivateControl.disable({ onlySelf: true });
|
||||
} else if (!ignorePrivate && deletePrivateControl) {
|
||||
// If not ignoring private, enable delete private (if parent section is enabled)
|
||||
const sectionEnabled = this.isSectionEnabled(section);
|
||||
if (sectionEnabled) {
|
||||
deletePrivateControl.enable({ onlySelf: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for changes to the 'useAdvancedScheduling' control
|
||||
const advancedControl = this.queueCleanerForm.get('useAdvancedScheduling');
|
||||
@@ -349,6 +388,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.originalFormValues = JSON.parse(JSON.stringify(this.queueCleanerForm.getRawValue()));
|
||||
this.hasActualChanges = false;
|
||||
}
|
||||
|
||||
// Helper method to check if a section is enabled
|
||||
private isSectionEnabled(section: string): boolean {
|
||||
const mainEnabled = this.queueCleanerForm.get('enabled')?.value || false;
|
||||
if (!mainEnabled) return false;
|
||||
|
||||
const maxStrikesControl = this.queueCleanerForm.get(`${section}.maxStrikes`);
|
||||
const maxStrikes = maxStrikesControl?.value || 0;
|
||||
|
||||
return maxStrikes >= 3;
|
||||
}
|
||||
|
||||
// Check if the current form values are different from the original values
|
||||
private formValuesChanged(): boolean {
|
||||
@@ -463,8 +513,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("ignoredPatterns")?.enable(options);
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.queueCleanerForm.get("failedImport.ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.queueCleanerForm.get("failedImport.deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable(options);
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable(options);
|
||||
}
|
||||
} else {
|
||||
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.disable(options);
|
||||
@@ -482,7 +541,16 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get("stalled")?.get("resetStrikesOnProgress")?.enable(options);
|
||||
this.queueCleanerForm.get("stalled")?.get("ignorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("stalled")?.get("deletePrivate")?.enable(options);
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.queueCleanerForm.get("stalled.ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.queueCleanerForm.get("stalled.deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable(options);
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable(options);
|
||||
}
|
||||
} else {
|
||||
this.queueCleanerForm.get("stalled")?.get("resetStrikesOnProgress")?.disable(options);
|
||||
this.queueCleanerForm.get("stalled")?.get("ignorePrivate")?.disable(options);
|
||||
@@ -500,10 +568,19 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get("slow")?.get("resetStrikesOnProgress")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("ignorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("deletePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("minSpeed")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("maxTime")?.enable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("ignoreAboveSize")?.enable(options);
|
||||
|
||||
// Only enable deletePrivate if ignorePrivate is false
|
||||
const ignorePrivate = this.queueCleanerForm.get("slow.ignorePrivate")?.value || false;
|
||||
const deletePrivateControl = this.queueCleanerForm.get("slow.deletePrivate");
|
||||
|
||||
if (!ignorePrivate && deletePrivateControl) {
|
||||
deletePrivateControl.enable(options);
|
||||
} else if (deletePrivateControl) {
|
||||
deletePrivateControl.disable(options);
|
||||
}
|
||||
} else {
|
||||
this.queueCleanerForm.get("slow")?.get("resetStrikesOnProgress")?.disable(options);
|
||||
this.queueCleanerForm.get("slow")?.get("ignorePrivate")?.disable(options);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="settings-container">
|
||||
<!-- Toast for notifications -->
|
||||
<p-toast></p-toast>
|
||||
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- General Settings Component -->
|
||||
<div class="mb-4">
|
||||
<app-general-settings></app-general-settings>
|
||||
</div>
|
||||
|
||||
<!-- Queue Cleaner Component -->
|
||||
<div class="mb-4">
|
||||
<app-queue-cleaner-settings></app-queue-cleaner-settings>
|
||||
</div>
|
||||
|
||||
<!-- Content Blocker Component -->
|
||||
<div class="mb-4">
|
||||
<app-content-blocker-settings></app-content-blocker-settings>
|
||||
</div>
|
||||
|
||||
<!-- Download Cleaner Component -->
|
||||
<div class="mb-4">
|
||||
<app-download-cleaner-settings></app-download-cleaner-settings>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SettingsPageComponent } from './settings-page.component';
|
||||
|
||||
describe('SettingsPageComponent', () => {
|
||||
let component: SettingsPageComponent;
|
||||
let fixture: ComponentFixture<SettingsPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SettingsPageComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { PanelModule } from 'primeng/panel';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
import { CanComponentDeactivate } from '../../core/guards';
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { MessageService, ConfirmationService } from 'primeng/api';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
|
||||
// Custom Components and Services
|
||||
import { QueueCleanerSettingsComponent } from '../queue-cleaner/queue-cleaner-settings.component';
|
||||
import { GeneralSettingsComponent } from '../general-settings/general-settings.component';
|
||||
import { DownloadCleanerSettingsComponent } from '../download-cleaner/download-cleaner-settings.component';
|
||||
import { SonarrSettingsComponent } from '../sonarr/sonarr-settings.component';
|
||||
import { ContentBlockerSettingsComponent } from "../content-blocker/content-blocker-settings.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
PanelModule,
|
||||
ButtonModule,
|
||||
DropdownModule,
|
||||
CardModule,
|
||||
ToastModule,
|
||||
ConfirmDialogModule,
|
||||
QueueCleanerSettingsComponent,
|
||||
GeneralSettingsComponent,
|
||||
DownloadCleanerSettingsComponent,
|
||||
ContentBlockerSettingsComponent
|
||||
],
|
||||
providers: [MessageService, ConfirmationService],
|
||||
templateUrl: './settings-page.component.html',
|
||||
styleUrl: './settings-page.component.scss'
|
||||
})
|
||||
export class SettingsPageComponent implements CanComponentDeactivate {
|
||||
// Reference to the settings components
|
||||
@ViewChild(QueueCleanerSettingsComponent) queueCleanerSettings!: QueueCleanerSettingsComponent;
|
||||
@ViewChild(GeneralSettingsComponent) generalSettings!: GeneralSettingsComponent;
|
||||
@ViewChild(DownloadCleanerSettingsComponent) downloadCleanerSettings!: DownloadCleanerSettingsComponent;
|
||||
@ViewChild(SonarrSettingsComponent) sonarrSettings!: SonarrSettingsComponent;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Future implementation for other settings sections
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements CanComponentDeactivate interface
|
||||
* Check if any settings components have unsaved changes
|
||||
*/
|
||||
canDeactivate(): boolean {
|
||||
// Check if queue cleaner settings has unsaved changes
|
||||
if (this.queueCleanerSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if general settings has unsaved changes
|
||||
if (this.generalSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if download cleaner settings has unsaved changes
|
||||
if (this.downloadCleanerSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if sonarr settings has unsaved changes
|
||||
if (this.sonarrSettings?.canDeactivate() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
margin-right: 0.5rem;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
font-size: 0.9em;
|
||||
font-size: 1em;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModu
|
||||
import { InputNumberModule } from 'primeng/inputnumber';
|
||||
import { SelectButtonModule } from 'primeng/selectbutton';
|
||||
|
||||
type ByteSizeUnit = 'KB' | 'MB' | 'GB' | 'TB';
|
||||
export type ByteSizeInputType = 'speed' | 'size';
|
||||
|
||||
type ByteSizeUnit = 'KB' | 'MB' | 'GB';
|
||||
|
||||
@Component({
|
||||
selector: 'app-byte-size-input',
|
||||
@@ -26,31 +28,57 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
|
||||
@Input() disabled: boolean = false;
|
||||
@Input() placeholder: string = 'Enter size';
|
||||
@Input() helpText: string = '';
|
||||
@Input() type: ByteSizeInputType = 'size';
|
||||
|
||||
// Value in the selected unit
|
||||
value = signal<number | null>(null);
|
||||
|
||||
|
||||
// The selected unit
|
||||
unit = signal<ByteSizeUnit>('MB');
|
||||
|
||||
// Available units
|
||||
unitOptions = [
|
||||
{ label: 'KB', value: 'KB' },
|
||||
{ label: 'MB', value: 'MB' },
|
||||
{ label: 'GB', value: 'GB' },
|
||||
{ label: 'TB', value: 'TB' }
|
||||
];
|
||||
|
||||
// Available units, computed based on type
|
||||
get unitOptions() {
|
||||
switch (this.type) {
|
||||
case 'speed':
|
||||
return [
|
||||
{ label: 'KB/s', value: 'KB' },
|
||||
{ label: 'MB/s', value: 'MB' }
|
||||
];
|
||||
case 'size':
|
||||
default:
|
||||
return [
|
||||
{ label: 'MB', value: 'MB' },
|
||||
{ label: 'GB', value: 'GB' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Get default unit based on type
|
||||
private getDefaultUnit(): ByteSizeUnit {
|
||||
switch (this.type) {
|
||||
case 'speed':
|
||||
return 'KB';
|
||||
case 'size':
|
||||
default:
|
||||
return 'MB';
|
||||
}
|
||||
}
|
||||
|
||||
// ControlValueAccessor interface methods
|
||||
private onChange: (value: string) => void = () => {};
|
||||
private onTouched: () => void = () => {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the string value in format '100MB', '1.5GB', etc.
|
||||
*/
|
||||
writeValue(value: string): void {
|
||||
if (!value) {
|
||||
this.value.set(null);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,15 +90,24 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
|
||||
if (match) {
|
||||
const numValue = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase() as ByteSizeUnit;
|
||||
|
||||
this.value.set(numValue);
|
||||
this.unit.set(unit);
|
||||
// Validate unit is allowed for this type
|
||||
const allowedUnits = this.unitOptions.map(opt => opt.value);
|
||||
if (allowedUnits.includes(unit)) {
|
||||
this.value.set(numValue);
|
||||
this.unit.set(unit);
|
||||
} else {
|
||||
// If unit not allowed, use default
|
||||
this.value.set(numValue);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
} else {
|
||||
this.value.set(null);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing byte size value:', value, e);
|
||||
this.value.set(null);
|
||||
this.unit.set(this.getDefaultUnit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +128,10 @@ export class ByteSizeInputComponent implements ControlValueAccessor {
|
||||
*/
|
||||
updateValue(): void {
|
||||
this.onTouched();
|
||||
|
||||
if (this.value() === null) {
|
||||
this.onChange('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format as "100MB", "1.5GB", etc.
|
||||
const formattedValue = `${this.value()}${this.unit()}`;
|
||||
this.onChange(formattedValue);
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ContentBlockerConfig {
|
||||
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
deleteKnownMalware: boolean;
|
||||
|
||||
sonarr: BlocklistSettings;
|
||||
radarr: BlocklistSettings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DownloadClientType } from './enums';
|
||||
import { DownloadClientType, DownloadClientTypeName } from './enums';
|
||||
|
||||
/**
|
||||
* Represents a download client configuration object
|
||||
@@ -37,7 +37,7 @@ export interface ClientConfig {
|
||||
/**
|
||||
* Type name of download client (backend enum)
|
||||
*/
|
||||
typeName?: string;
|
||||
typeName: DownloadClientTypeName;
|
||||
|
||||
/**
|
||||
* Host address for the download client
|
||||
@@ -73,16 +73,16 @@ export interface CreateDownloadClientDto {
|
||||
* Friendly name for this client
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Type of download client (backend enum)
|
||||
*/
|
||||
type: DownloadClientType;
|
||||
|
||||
/**
|
||||
* Type name of download client (backend enum)
|
||||
*/
|
||||
typeName: string;
|
||||
|
||||
/**
|
||||
* Type of download client (backend enum)
|
||||
*/
|
||||
type: string;
|
||||
typeName: DownloadClientTypeName;
|
||||
|
||||
/**
|
||||
* Host address for the download client
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Download client type enum matching backend DownloadClientType
|
||||
*/
|
||||
export enum DownloadClientType {
|
||||
QBittorrent = 0,
|
||||
Deluge = 1,
|
||||
Transmission = 2,
|
||||
Torrent = "Torrent",
|
||||
Usenet = "Usenet",
|
||||
}
|
||||
|
||||
export enum DownloadClientTypeName {
|
||||
qBittorrent = "qBittorrent",
|
||||
Deluge = "Deluge",
|
||||
Transmission = "Transmission",
|
||||
uTorrent = "uTorrent",
|
||||
}
|
||||
@@ -18,8 +18,8 @@ Cleanuparr integrates with popular *arr applications and download clients for co
|
||||
|
||||
| **Category** | **Application** | **Integration** |
|
||||
|--------------|-----------------|-----------------|
|
||||
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr | Full API integration for queue monitoring and search triggers |
|
||||
| **Download Clients** | qBittorrent, Deluge, Transmission | Complete download management and monitoring |
|
||||
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr, Whisparr | Full API integration for queue monitoring and search triggers |
|
||||
| **Download Clients** | qBittorrent, Deluge, Transmission, µTorrent | Complete download management and monitoring |
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ This is a detailed explanation of how the recurring cleanup jobs work.
|
||||
icon="🧹"
|
||||
>
|
||||
|
||||
- Run every 5 minutes (or configured cron, or right after `Content Blocker`).
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**.
|
||||
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
|
||||
|
||||
@@ -83,6 +83,25 @@ Setting this to true means private torrents will be permanently deleted, potenti
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
id="delete-known-malware"
|
||||
title="Delete Known Malware"
|
||||
icon="🦠"
|
||||
>
|
||||
|
||||
When enabled, downloads that match known malware patterns will be automatically deleted from the download client.
|
||||
|
||||
**Malware Detection Source:**
|
||||
- List is automatically fetched from: `https://cleanuparr.pages.dev/static/known_malware_file_name_patterns`
|
||||
- Updates automatically every **5 minutes**
|
||||
- Contains filename patterns known to be associated with malware
|
||||
|
||||
<EnhancedWarning>
|
||||
This feature permanently deletes downloads that match malware patterns. While the patterns are carefully curated, false positives are possible. Monitor logs carefully when first enabling this feature.
|
||||
</EnhancedWarning>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
@@ -118,7 +137,12 @@ Path to the blocklist file or URL. This can be a local file path or a remote URL
|
||||
- `/config/sonarr-blocklist.txt`
|
||||
- `https://example.com/blocklist.txt`
|
||||
|
||||
The blocklists support the following types of patters:
|
||||
**Automatic Reload Intervals:**
|
||||
- **Cleanuparr Official Lists** (`cleanuparr.pages.dev`): Every **5 minutes**
|
||||
- **Other Remote URLs**: Every **4 hours**
|
||||
- **Local Files**: Every **5 minutes**
|
||||
|
||||
The blocklists support the following types of patterns:
|
||||
```
|
||||
*example // file name ends with "example"
|
||||
example* // file name starts with "example"
|
||||
@@ -127,6 +151,14 @@ example // file name is exactly the word "example"
|
||||
regex:<ANY_REGEX> // regex that needs to be marked at the start of the line with "regex:"
|
||||
```
|
||||
|
||||
:::tip
|
||||
Available blocklists that can be used with Sonarr and Radarr:
|
||||
- `http://cleanuparr.pages.dev/static/blacklist`
|
||||
- `http://cleanuparr.pages.dev/static/blacklist_permissive`
|
||||
- `http://cleanuparr.pages.dev/static/whitelist`
|
||||
- `http://cleanuparr.pages.dev/static/whitelist_with_subtitles`
|
||||
:::
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
@@ -139,10 +171,6 @@ Controls how the blocklist is interpreted:
|
||||
- **Blacklist**: Files matching any pattern in the list will be blocked.
|
||||
- **Whitelist**: Only files matching patterns in the list will be allowed.
|
||||
|
||||
:::tip
|
||||
[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) can be used for Sonarr and Radarr.
|
||||
:::
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
# Download Client
|
||||
|
||||
Configure download client connections for torrents and usenet. Cleanuparr supports qBittorrent, Deluge, and Transmission download clients.
|
||||
Configure download client connections for torrents. Cleanuparr supports qBittorrent, Deluge, Transmission and µTorrent download clients.
|
||||
|
||||
<div className={styles.documentationPage}>
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ Downloads matching these patterns will be ignored during all cleaning operations
|
||||
- qBittorrent tag or category
|
||||
- Deluge label
|
||||
- Transmission category (last directory from the save location)
|
||||
- µTorrent label
|
||||
- torrent tracker domain
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -45,6 +45,20 @@ services:
|
||||
- TZ=Etc/UTC
|
||||
```
|
||||
|
||||
## Unraid
|
||||
|
||||
Use the Unraid template available in the Community Applications plugin. If the template is not yet available, you can manually add using the above Docker Compose configuration or use this guide to create a custom template:
|
||||
|
||||
1. Download the template from here: https://github.com/Cleanuparr/unraid/blob/main/templates/Cleanuparr.xml
|
||||
2. Rename the downloaded file to 'my-cleanuparr.xml'
|
||||
3. Drop it in the `/boot/config/plugins/dockerMan/templates-user/` folder of your Unraid machine
|
||||
4. Go to the Docker tab of the Unraid webui
|
||||
5. Click on Add Container
|
||||
6. From the `Template` dropdown menu, select `cleanuparr`
|
||||
7. Set the repository to `ghcr.io/cleanuparr/cleanuparr:latest`
|
||||
8. Most other settings can be left at default, with the exception of the downloads folder which should be mapped to where your *arr stack / torrent client downloads its files to
|
||||
|
||||
|
||||
## 🖥️ Other Installation Methods
|
||||
|
||||
- **Windows**: Download the installer from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import Admonition from "../Admonition";
|
||||
|
||||
export type DescriptionContent =
|
||||
| string
|
||||
| {
|
||||
type: "code" | "list";
|
||||
title: string;
|
||||
content: string | string[];
|
||||
};
|
||||
|
||||
export interface EnvVarProps {
|
||||
name: string;
|
||||
description: DescriptionContent[];
|
||||
type: string;
|
||||
reference?: string;
|
||||
required?: boolean | string;
|
||||
defaultValue: string;
|
||||
defaultValueComment?: string;
|
||||
examples?: string[];
|
||||
acceptedValues?: string[];
|
||||
children?: React.ReactNode;
|
||||
notes?: string[];
|
||||
important?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface EnvVarsProps {
|
||||
vars: EnvVarProps[];
|
||||
}
|
||||
|
||||
export default function EnvVars({ vars }: EnvVarsProps) {
|
||||
return vars.map((env) => <EnvVar key={env.name} env={env} />);
|
||||
}
|
||||
|
||||
function EnvVar({ env }: { env: EnvVarProps }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const queryKeys = Array.from(searchParams.keys());
|
||||
|
||||
const matched = queryKeys.find(
|
||||
(key) => key.toLowerCase() === env.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (matched && ref.current) {
|
||||
// Scroll to the variable
|
||||
ref.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
// Add highlight effect
|
||||
ref.current.classList.add("env-var-highlight");
|
||||
|
||||
setTimeout(() => {
|
||||
ref.current.classList.add("highlight-removing");
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
ref.current.classList.remove("env-var-highlight", "highlight-removing");
|
||||
}, 3000);
|
||||
}
|
||||
}, [location.search, env.name]);
|
||||
|
||||
const renderDescriptionContent = (
|
||||
content: DescriptionContent,
|
||||
index: number
|
||||
) => {
|
||||
if (typeof content === "string") {
|
||||
return <ReactMarkdown components={{ p: ({ children }) => <div>{children}</div> }}>{content}</ReactMarkdown>;
|
||||
}
|
||||
|
||||
switch (content.type) {
|
||||
case "code":
|
||||
return (
|
||||
<section>
|
||||
{content.title && <strong>{content.title}</strong>}
|
||||
<br />
|
||||
<pre key={index}>
|
||||
{content.content}
|
||||
</pre>
|
||||
</section>
|
||||
);
|
||||
case "list":
|
||||
return (
|
||||
<section>
|
||||
{content.title && <strong>{content.title}</strong>}
|
||||
<br />
|
||||
<ul key={index}>
|
||||
{(Array.isArray(content.content)
|
||||
? content.content
|
||||
: [content.content]
|
||||
).map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAdmonition = (type: "important" | "warning" | "note", items: string[]) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Admonition type={type}>
|
||||
<ul>
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<ReactMarkdown components={{ p: ({ children }) => <>{children}</> }}>
|
||||
{item}
|
||||
</ReactMarkdown>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Admonition>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={env.name} ref={ref} className="env-var-block">
|
||||
<h3>
|
||||
<code>{env.name}</code>
|
||||
</h3>
|
||||
{env.description.map((desc, index) =>
|
||||
renderDescriptionContent(desc, index)
|
||||
)}
|
||||
{env.required !== undefined && (
|
||||
<section>
|
||||
<strong>Required: </strong>
|
||||
{typeof env.required === "boolean"
|
||||
? env.required
|
||||
? "Yes"
|
||||
: "No"
|
||||
: env.required}
|
||||
</section>
|
||||
)}
|
||||
{env.type !== undefined && (
|
||||
<section>
|
||||
<strong>Type: </strong>
|
||||
{env.type}
|
||||
</section>
|
||||
)}
|
||||
{env.defaultValue !== undefined && (
|
||||
<section>
|
||||
<strong>Default value: </strong>
|
||||
<code>{env.defaultValue}</code> {env.defaultValueComment !== undefined && (`(${env.defaultValueComment})`)}
|
||||
</section>
|
||||
)}
|
||||
{env.reference !== undefined && (
|
||||
<section>
|
||||
<strong>Reference: </strong>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <>{children}</>, // No wrapping <p> tag
|
||||
}}
|
||||
>
|
||||
{`[Quartz.NET](${env.reference})`}
|
||||
</ReactMarkdown>
|
||||
</section>
|
||||
)}
|
||||
{env.acceptedValues && env.acceptedValues.length > 0 && (
|
||||
<section>
|
||||
<strong>Accepted values:</strong>
|
||||
<ul>
|
||||
{env.acceptedValues.map((example, index) => (
|
||||
<li key={index}>
|
||||
<code>{example}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{env.examples && env.examples.length > 0 && (
|
||||
<section>
|
||||
<strong>Examples:</strong>
|
||||
<ul>
|
||||
{env.examples.map((example, index) => (
|
||||
<li key={index}>
|
||||
<code>{example}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{env.notes && renderAdmonition("note", env.notes)}
|
||||
{env.important && renderAdmonition("important", env.important)}
|
||||
{env.warnings && renderAdmonition("warning", env.warnings)}
|
||||
|
||||
<div style={{ marginTop: "0.5rem" }}>{env.children}</div>
|
||||
</div>
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "./EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "TZ",
|
||||
description: [
|
||||
"The time zone to use."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "UTC",
|
||||
required: false,
|
||||
examples: ["America/New_York", "Europe/London", "Asia/Tokyo"],
|
||||
},
|
||||
{
|
||||
name: "DRY_RUN",
|
||||
description: [
|
||||
"When enabled, simulates irreversible operations (like deletions and notifications) without making actual changes."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "LOGGING__LOGLEVEL",
|
||||
description: [
|
||||
"Controls the detail level of application logs."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Information",
|
||||
required: false,
|
||||
acceptedValues: ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"],
|
||||
},
|
||||
{
|
||||
name: "LOGGING__FILE__ENABLED",
|
||||
description: [
|
||||
"Enables logging to a file."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "LOGGING__FILE__PATH",
|
||||
description: [
|
||||
"Directory where log files will be saved."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty (file is saved where the app is)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "LOGGING__ENHANCED",
|
||||
description: [
|
||||
"Provides more detailed descriptions in logs whenever possible.",
|
||||
"Will be deprecated in a future version."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "HTTP_MAX_RETRIES",
|
||||
description: [
|
||||
"The number of times to retry a failed HTTP call.",
|
||||
"Applies when communicating with *arrs, download clients and other services through HTTP calls."
|
||||
],
|
||||
type: "positive integer number",
|
||||
defaultValue: "0",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP_TIMEOUT",
|
||||
description: [
|
||||
"The number of seconds to wait before failing an HTTP call.",
|
||||
"Applies to calls to *arrs, download clients, and other services."
|
||||
],
|
||||
type: "positive integer number",
|
||||
defaultValue: "100",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP_VALIDATE_CERT",
|
||||
description: [
|
||||
"Controls whether to validate SSL certificates for HTTPS connections.",
|
||||
"Set to `Disabled` to ignore SSL certificate errors."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Enabled",
|
||||
required: false,
|
||||
acceptedValues: ["Enabled", "DisabledForLocalAddresses", "Disabled"],
|
||||
}
|
||||
];
|
||||
|
||||
export default function GeneralSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "./EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "SEARCH_ENABLED",
|
||||
description: [
|
||||
"Enabled searching for replacements after a download has been removed from an arr."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
notes: [
|
||||
"If you are using [Huntarr](https://github.com/plexguide/Huntarr.io), this setting should be set to false to let Huntarr do the searching.",
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "SEARCH_DELAY",
|
||||
description: [
|
||||
"If searching for replacements is enabled, this setting will delay the searches by the specified number of seconds.",
|
||||
"This is useful to avoid overwhelming the indexer with too many requests at once.",
|
||||
],
|
||||
type: "positive integer number",
|
||||
defaultValue: "30",
|
||||
required: false,
|
||||
important: [
|
||||
"A lower value or `0` will result in faster searches, but may cause issues such as being rate limited or banned by the indexer.",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export default function SearchSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user