mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-02-20 07:46:34 -05:00
try remove content blocker
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
|
||||
public sealed record ContentBlockerConfig : IJobConfig
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string CronExpression { get; init; } = "0 0/5 * * * ?";
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
// TODO
|
||||
public string IgnoredDownloadsPath { get; init; } = string.Empty;
|
||||
|
||||
public BlocklistSettings Sonarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Radarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Lidarr { get; init; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public record BlocklistSettings
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public BlocklistType Type { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
|
||||
namespace Common.Configuration.DTOs.ContentBlocker;
|
||||
|
||||
|
||||
11
code/Common/Configuration/QueueCleaner/BlocklistSettings.cs
Normal file
11
code/Common/Configuration/QueueCleaner/BlocklistSettings.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Common.Configuration.QueueCleaner;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for a blocklist
|
||||
/// </summary>
|
||||
public sealed record BlocklistSettings
|
||||
{
|
||||
public BlocklistType BlocklistType { get; init; }
|
||||
|
||||
public string? BlocklistPath { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
namespace Common.Configuration.QueueCleaner;
|
||||
|
||||
public enum BlocklistType
|
||||
{
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Common.Configuration.QueueCleaner;
|
||||
|
||||
public sealed record ContentBlockerConfig
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Radarr { get; init; } = new();
|
||||
|
||||
public BlocklistSettings Lidarr { get; init; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -12,94 +12,123 @@ public sealed record QueueCleanerConfig : IJobConfig
|
||||
|
||||
public string CronExpression { get; init; } = "0 0/5 * * * ?";
|
||||
|
||||
public bool RunSequentially { get; init; }
|
||||
|
||||
public string IgnoredDownloadsPath { get; init; } = string.Empty;
|
||||
|
||||
public ushort FailedImportMaxStrikes { get; init; }
|
||||
|
||||
public bool FailedImportIgnorePrivate { get; init; }
|
||||
|
||||
public bool FailedImportDeletePrivate { get; init; }
|
||||
|
||||
public IReadOnlyList<string> FailedImportIgnorePatterns { get; init; } = [];
|
||||
public FailedImportConfig FailedImport { get; init; } = new();
|
||||
|
||||
public ushort StalledMaxStrikes { get; init; }
|
||||
public StalledConfig Stalled { get; init; } = new();
|
||||
|
||||
public bool StalledResetStrikesOnProgress { get; init; }
|
||||
public SlowConfig Slow { get; init; } = new();
|
||||
|
||||
public bool StalledIgnorePrivate { get; init; }
|
||||
|
||||
public bool StalledDeletePrivate { get; init; }
|
||||
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
|
||||
public ushort SlowMaxStrikes { get; init; }
|
||||
|
||||
public bool SlowResetStrikesOnProgress { get; init; }
|
||||
|
||||
public bool SlowIgnorePrivate { get; init; }
|
||||
|
||||
public bool SlowDeletePrivate { get; init; }
|
||||
|
||||
public string SlowMinSpeed { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public ByteSize SlowMinSpeedByteSize => string.IsNullOrEmpty(SlowMinSpeed) ? new ByteSize(0) : ByteSize.Parse(SlowMinSpeed);
|
||||
|
||||
public double SlowMaxTime { get; init; }
|
||||
|
||||
public string SlowIgnoreAboveSize { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public ByteSize? SlowIgnoreAboveSizeByteSize => string.IsNullOrEmpty(SlowIgnoreAboveSize) ? null : ByteSize.Parse(SlowIgnoreAboveSize);
|
||||
public ContentBlockerConfig ContentBlocker { get; init; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (FailedImportMaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__IMPORT_FAILED_MAX_STRIKES must be 3");
|
||||
}
|
||||
FailedImport.Validate(SectionName);
|
||||
Stalled.Validate(SectionName);
|
||||
Slow.Validate(SectionName);
|
||||
ContentBlocker.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
if (StalledMaxStrikes is > 0 and < 3)
|
||||
public sealed record FailedImportConfig
|
||||
{
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public IReadOnlyList<string> IgnoredPatterns { get; init; } = [];
|
||||
|
||||
public void Validate(string sectionName)
|
||||
{
|
||||
if (MaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__STALLED_MAX_STRIKES must be 3");
|
||||
throw new ValidationException($"the minimum value for {sectionName.ToUpperInvariant()}__FAILED_IMPORT__MAX_STRIKES must be 3");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record StalledConfig
|
||||
{
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public bool ResetStrikesOnProgress { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
|
||||
public void Validate(string sectionName)
|
||||
{
|
||||
if (MaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {sectionName.ToUpperInvariant()}__STALLED__MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (DownloadingMetadataMaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__DOWNLOADING_METADATA_MAX_STRIKES must be 3");
|
||||
throw new ValidationException($"the minimum value for {sectionName.ToUpperInvariant()}__STALLED__DOWNLOADING_METADATA_MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (SlowMaxStrikes is > 0 and < 3)
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SlowConfig
|
||||
{
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public bool ResetStrikesOnProgress { get; init; }
|
||||
|
||||
public bool IgnorePrivate { get; init; }
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public string MinSpeed { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public ByteSize MinSpeedByteSize => string.IsNullOrEmpty(MinSpeed) ? new ByteSize(0) : ByteSize.Parse(MinSpeed);
|
||||
|
||||
public double MaxTime { get; init; }
|
||||
|
||||
public string IgnoreAboveSize { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public ByteSize? IgnoreAboveSizeByteSize => string.IsNullOrEmpty(IgnoreAboveSize) ? null : ByteSize.Parse(IgnoreAboveSize);
|
||||
|
||||
public void Validate(string sectionName)
|
||||
{
|
||||
if (MaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be 3");
|
||||
throw new ValidationException($"the minimum value for {sectionName.ToUpperInvariant()}__SLOW__MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (SlowMaxStrikes > 0)
|
||||
if (MaxStrikes > 0)
|
||||
{
|
||||
bool isSlowSpeedSet = !string.IsNullOrEmpty(SlowMinSpeed);
|
||||
bool isSpeedSet = !string.IsNullOrEmpty(MinSpeed);
|
||||
|
||||
if (isSlowSpeedSet && ByteSize.TryParse(SlowMinSpeed, out _) is false)
|
||||
if (isSpeedSet && ByteSize.TryParse(MinSpeed, out _) is false)
|
||||
{
|
||||
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED");
|
||||
throw new ValidationException($"invalid value for {sectionName.ToUpperInvariant()}__SLOW__MIN_SPEED");
|
||||
}
|
||||
|
||||
if (SlowMaxTime < 0)
|
||||
if (MaxTime < 0)
|
||||
{
|
||||
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MAX_TIME");
|
||||
throw new ValidationException($"invalid value for {sectionName.ToUpperInvariant()}__SLOW__MAX_TIME");
|
||||
}
|
||||
|
||||
if (!isSlowSpeedSet && SlowMaxTime is 0)
|
||||
if (!isSpeedSet && MaxTime is 0)
|
||||
{
|
||||
throw new ValidationException($"either {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED or {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be set");
|
||||
throw new ValidationException($"either {sectionName.ToUpperInvariant()}__SLOW__MIN_SPEED or {sectionName.ToUpperInvariant()}__SLOW__MAX_TIME must be set");
|
||||
}
|
||||
|
||||
bool isSlowIgnoreAboveSizeSet = !string.IsNullOrEmpty(SlowIgnoreAboveSize);
|
||||
bool isIgnoreAboveSizeSet = !string.IsNullOrEmpty(IgnoreAboveSize);
|
||||
|
||||
if (isSlowIgnoreAboveSizeSet && ByteSize.TryParse(SlowIgnoreAboveSize, out _) is false)
|
||||
if (isIgnoreAboveSizeSet && ByteSize.TryParse(IgnoreAboveSize, out _) is false)
|
||||
{
|
||||
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_IGNORE_ABOVE_SIZE");
|
||||
throw new ValidationException($"invalid value for {sectionName.ToUpperInvariant()}__SLOW__IGNORE_ABOVE_SIZE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.DTOs.Arr;
|
||||
|
||||
@@ -29,12 +29,8 @@ public static class LoggingDI
|
||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
||||
|
||||
// Determine categories and padding sizes
|
||||
List<string> categories = ["SYSTEM", "API", "JOBS", "NOTIFICATIONS"];
|
||||
int catPadding = categories.Max(x => x.Length) + 2;
|
||||
|
||||
// Determine job name padding
|
||||
List<string> jobNames = [nameof(ContentBlocker), nameof(QueueCleaner), nameof(DownloadCleaner)];
|
||||
List<string> jobNames = [nameof(QueueCleaner), nameof(DownloadCleaner)];
|
||||
int jobPadding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Determine instance name padding
|
||||
@@ -46,7 +42,7 @@ public static class LoggingDI
|
||||
InstanceType.Whisparr.ToString(),
|
||||
"SYSTEM"
|
||||
];
|
||||
int arrPadding = categoryNames.Max(x => x.Length) + 2;
|
||||
int catPadding = categoryNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Apply padding values to templates
|
||||
string consoleTemplate = consoleOutputTemplate
|
||||
|
||||
@@ -38,7 +38,6 @@ public static class ServicesDI
|
||||
.AddTransient<LidarrClient>()
|
||||
.AddTransient<ArrClientFactory>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<DownloadCleaner>()
|
||||
.AddTransient<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadCleaner;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
@@ -28,8 +24,8 @@ public class BackgroundJobManager : IHostedService
|
||||
public BackgroundJobManager(
|
||||
ISchedulerFactory schedulerFactory,
|
||||
IConfigManager configManager,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<BackgroundJobManager> logger)
|
||||
ILogger<BackgroundJobManager> logger
|
||||
)
|
||||
{
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_configManager = configManager;
|
||||
@@ -78,48 +74,11 @@ public class BackgroundJobManager : IHostedService
|
||||
}
|
||||
|
||||
// Get configurations from JSON files
|
||||
ContentBlockerConfig? contentBlockerConfig = await _configManager.GetConfigurationAsync<ContentBlockerConfig>();
|
||||
QueueCleanerConfig? queueCleanerConfig = await _configManager.GetConfigurationAsync<QueueCleanerConfig>();
|
||||
DownloadCleanerConfig? downloadCleanerConfig = await _configManager.GetConfigurationAsync<DownloadCleanerConfig>();
|
||||
QueueCleanerConfig queueCleanerConfig = await _configManager.GetConfigurationAsync<QueueCleanerConfig>();
|
||||
DownloadCleanerConfig downloadCleanerConfig = await _configManager.GetConfigurationAsync<DownloadCleanerConfig>();
|
||||
|
||||
// Add ContentBlocker job if enabled
|
||||
if (contentBlockerConfig?.Enabled == true)
|
||||
{
|
||||
await AddContentBlockerJob(contentBlockerConfig, cancellationToken);
|
||||
}
|
||||
|
||||
// Add QueueCleaner job if enabled
|
||||
if (queueCleanerConfig?.Enabled == true)
|
||||
{
|
||||
// Check if we need to chain it after ContentBlocker
|
||||
bool shouldChainAfterContentBlocker =
|
||||
contentBlockerConfig?.Enabled == true &&
|
||||
queueCleanerConfig.RunSequentially;
|
||||
|
||||
await AddQueueCleanerJob(queueCleanerConfig, shouldChainAfterContentBlocker, cancellationToken);
|
||||
}
|
||||
|
||||
// Add DownloadCleaner job if enabled
|
||||
if (downloadCleanerConfig?.Enabled == true)
|
||||
{
|
||||
await AddDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the ContentBlocker job to the scheduler.
|
||||
/// </summary>
|
||||
public async Task AddContentBlockerJob(ContentBlockerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AddJobWithTrigger<ContentBlocker>(
|
||||
config,
|
||||
config.CronExpression,
|
||||
cancellationToken);
|
||||
await AddQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await AddDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -127,7 +86,6 @@ public class BackgroundJobManager : IHostedService
|
||||
/// </summary>
|
||||
public async Task AddQueueCleanerJob(
|
||||
QueueCleanerConfig config,
|
||||
bool chainAfterContentBlocker = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
@@ -135,28 +93,10 @@ public class BackgroundJobManager : IHostedService
|
||||
return;
|
||||
}
|
||||
|
||||
var jobKey = new JobKey(nameof(QueueCleaner));
|
||||
|
||||
// If the job should be chained after ContentBlocker, add it without a cron trigger
|
||||
if (chainAfterContentBlocker)
|
||||
{
|
||||
await AddJobWithoutTrigger<QueueCleaner>(cancellationToken);
|
||||
|
||||
// Add job listener to chain QueueCleaner after ContentBlocker
|
||||
if (_scheduler != null)
|
||||
{
|
||||
var chainListener = new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner));
|
||||
_scheduler.ListenerManager.AddJobListener(chainListener, KeyMatcher<JobKey>.KeyEquals(new JobKey(nameof(ContentBlocker))));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add job with normal cron trigger
|
||||
await AddJobWithTrigger<QueueCleaner>(
|
||||
config,
|
||||
config.CronExpression,
|
||||
cancellationToken);
|
||||
}
|
||||
await AddJobWithTrigger<QueueCleaner>(
|
||||
config,
|
||||
config.CronExpression,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Shouldly;
|
||||
|
||||
namespace Infrastructure.Tests.Verticals.ContentBlocker;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Configuration;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Reflection;
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.General;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.General;
|
||||
|
||||
@@ -72,9 +72,9 @@ public abstract class ArrClient : IArrClient
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes)
|
||||
{
|
||||
if (_queueCleanerConfig.FailedImportIgnorePrivate && isPrivateDownload)
|
||||
if (_queueCleanerConfig.FailedImport.IgnorePrivate && isPrivateDownload)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip failed import check | download is private | {name}", record.Title);
|
||||
@@ -108,7 +108,7 @@ public abstract class ArrClient : IArrClient
|
||||
return false;
|
||||
}
|
||||
|
||||
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : _queueCleanerConfig.FailedImportMaxStrikes;
|
||||
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : _queueCleanerConfig.FailedImport.MaxStrikes;
|
||||
|
||||
return await _striker.StrikeAndCheckLimit(
|
||||
record.DownloadId,
|
||||
@@ -214,7 +214,7 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
private bool HasIgnoredPatterns(QueueRecord record)
|
||||
{
|
||||
if (_queueCleanerConfig.FailedImportIgnorePatterns.Count is 0)
|
||||
if (_queueCleanerConfig.FailedImport.IgnoredPatterns.Count is 0)
|
||||
{
|
||||
// no patterns are configured
|
||||
return false;
|
||||
@@ -234,7 +234,7 @@ public abstract class ArrClient : IArrClient
|
||||
.ForEach(x => messages.Add(x));
|
||||
|
||||
return messages.Any(
|
||||
m => _queueCleanerConfig.FailedImportIgnorePatterns.Any(
|
||||
m => _queueCleanerConfig.FailedImport.IgnoredPatterns.Any(
|
||||
p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ public interface IArrClient
|
||||
{
|
||||
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
|
||||
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes);
|
||||
|
||||
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Data.Enums;
|
||||
using Infrastructure.Configuration;
|
||||
@@ -14,8 +14,7 @@ namespace Infrastructure.Verticals.ContentBlocker;
|
||||
public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly IConfigManager _configManager;
|
||||
private ContentBlockerConfig _contentBlockerConfig;
|
||||
private readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private bool _initialized;
|
||||
@@ -28,11 +27,10 @@ public sealed class BlocklistProvider
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_configManager = configManager;
|
||||
_cache = cache;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
|
||||
_contentBlockerConfig = _configManager.GetConfiguration<ContentBlockerConfig>();
|
||||
_queueCleanerConfig = configManager.GetConfiguration<QueueCleanerConfig>();
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistsAsync()
|
||||
@@ -45,9 +43,9 @@ public sealed class BlocklistProvider
|
||||
|
||||
try
|
||||
{
|
||||
await LoadPatternsAndRegexesAsync(_contentBlockerConfig.Sonarr, InstanceType.Sonarr);
|
||||
await LoadPatternsAndRegexesAsync(_contentBlockerConfig.Radarr, InstanceType.Radarr);
|
||||
await LoadPatternsAndRegexesAsync(_contentBlockerConfig.Lidarr, InstanceType.Lidarr);
|
||||
await LoadPatternsAndRegexesAsync(_queueCleanerConfig.ContentBlocker.Sonarr, InstanceType.Sonarr);
|
||||
await LoadPatternsAndRegexesAsync(_queueCleanerConfig.ContentBlocker.Radarr, InstanceType.Radarr);
|
||||
await LoadPatternsAndRegexesAsync(_queueCleanerConfig.ContentBlocker.Lidarr, InstanceType.Lidarr);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
@@ -81,17 +79,12 @@ public sealed class BlocklistProvider
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
|
||||
{
|
||||
if (!blocklistSettings.Enabled)
|
||||
if (string.IsNullOrEmpty(blocklistSettings.BlocklistPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(blocklistSettings.Path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(blocklistSettings.Path);
|
||||
string[] filePatterns = await ReadContentAsync(blocklistSettings.BlocklistPath);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
@@ -122,13 +115,13 @@ public sealed class BlocklistProvider
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_cache.Set(CacheKeys.BlocklistType(instanceType), blocklistSettings.Type);
|
||||
_cache.Set(CacheKeys.BlocklistType(instanceType), blocklistSettings.BlocklistType);
|
||||
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
|
||||
_cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes);
|
||||
|
||||
_logger.LogDebug("loaded {count} patterns", patterns.Count);
|
||||
_logger.LogDebug("loaded {count} regexes", regexes.Count);
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistSettings.Path);
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistSettings.BlocklistPath);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Data.Enums;
|
||||
using Data.Models.Arr.Queue;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Helpers;
|
||||
using Infrastructure.Services;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.Arr.Interfaces;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LogContext = Serilog.Context.LogContext;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class ContentBlocker : GenericHandler
|
||||
{
|
||||
private readonly ContentBlockerConfig _config;
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
private readonly IIgnoredDownloadsService _ignoredDownloadsService;
|
||||
|
||||
public ContentBlocker(
|
||||
ILogger<ContentBlocker> logger,
|
||||
IConfigManager configManager,
|
||||
IMemoryCache cache,
|
||||
IBus messageBus,
|
||||
ArrClientFactory arrClientFactory,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
BlocklistProvider blocklistProvider,
|
||||
IIgnoredDownloadsService ignoredDownloadsService,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(
|
||||
logger, cache, messageBus,
|
||||
arrClientFactory, arrArrQueueIterator, downloadServiceFactory
|
||||
)
|
||||
{
|
||||
_blocklistProvider = blocklistProvider;
|
||||
_ignoredDownloadsService = ignoredDownloadsService;
|
||||
|
||||
_config = configManager.GetConfiguration<ContentBlockerConfig>();
|
||||
_downloadClientConfig = configManager.GetConfiguration<DownloadClientConfig>();
|
||||
_sonarrConfig = configManager.GetConfiguration<SonarrConfig>();
|
||||
_radarrConfig = configManager.GetConfiguration<RadarrConfig>();
|
||||
_lidarrConfig = configManager.GetConfiguration<LidarrConfig>();
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
if (_downloadClientConfig.Clients.Count is 0)
|
||||
{
|
||||
_logger.LogWarning("No download clients configured");
|
||||
return;
|
||||
}
|
||||
|
||||
bool blocklistIsConfigured = _config.Sonarr.Enabled && !string.IsNullOrEmpty(_config.Sonarr.Path) ||
|
||||
_config.Radarr.Enabled && !string.IsNullOrEmpty(_config.Radarr.Path) ||
|
||||
_config.Lidarr.Enabled && !string.IsNullOrEmpty(_config.Lidarr.Path);
|
||||
|
||||
if (!blocklistIsConfigured)
|
||||
{
|
||||
_logger.LogWarning("no blocklist is configured");
|
||||
return;
|
||||
}
|
||||
|
||||
await _blocklistProvider.LoadBlocklistsAsync();
|
||||
await base.ExecuteAsync();
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
|
||||
{
|
||||
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsService.GetIgnoredDownloadsAsync();
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueRecord record = group.First();
|
||||
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("skip | {title} | ignored", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogTrace("processing | {name}", record.Title);
|
||||
|
||||
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
|
||||
|
||||
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
{
|
||||
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
bool found = false;
|
||||
|
||||
foreach (var downloadService in _downloadServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
BlockFilesResult result = await downloadService
|
||||
.BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes, ignoredDownloads);
|
||||
|
||||
if (!result.Found)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
found = true;
|
||||
|
||||
if (!result.ShouldRemove)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogDebug("all files are marked as unwanted | {hash}", record.Title);
|
||||
|
||||
bool removeFromClient = true;
|
||||
|
||||
if (result.IsPrivate && !_config.DeletePrivate)
|
||||
{
|
||||
removeFromClient = false;
|
||||
}
|
||||
|
||||
await PublishQueueItemRemoveRequest(
|
||||
downloadRemovalKey,
|
||||
instanceType,
|
||||
instance,
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
DeleteReason.AllFilesBlocked
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error blocking unwanted files for {hash} with download client",
|
||||
record.DownloadId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
_logger.LogWarning("skip | download not found {title}", record.Title);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// using System.Collections.Concurrent;
|
||||
// using System.Text.RegularExpressions;
|
||||
// using Common.Configuration.Arr;
|
||||
// using Common.Configuration.DownloadClient;
|
||||
// using Common.Configuration.QueueCleaner;
|
||||
// using Data.Enums;
|
||||
// using Data.Models.Arr.Queue;
|
||||
// using Infrastructure.Configuration;
|
||||
// using Infrastructure.Helpers;
|
||||
// using Infrastructure.Services;
|
||||
// using Infrastructure.Verticals.Arr;
|
||||
// using Infrastructure.Verticals.Arr.Interfaces;
|
||||
// using Infrastructure.Verticals.DownloadClient;
|
||||
// using Infrastructure.Verticals.Jobs;
|
||||
// using MassTransit;
|
||||
// using Microsoft.Extensions.Caching.Memory;
|
||||
// using Microsoft.Extensions.Logging;
|
||||
// using LogContext = Serilog.Context.LogContext;
|
||||
//
|
||||
// namespace Infrastructure.Verticals.ContentBlocker;
|
||||
//
|
||||
// public sealed class ContentBlocker : GenericHandler
|
||||
// {
|
||||
// private readonly ContentBlockerConfig _config;
|
||||
// private readonly BlocklistProvider _blocklistProvider;
|
||||
// private readonly IIgnoredDownloadsService _ignoredDownloadsService;
|
||||
//
|
||||
// public ContentBlocker(
|
||||
// ILogger<ContentBlocker> logger,
|
||||
// IConfigManager configManager,
|
||||
// IMemoryCache cache,
|
||||
// IBus messageBus,
|
||||
// ArrClientFactory arrClientFactory,
|
||||
// ArrQueueIterator arrArrQueueIterator,
|
||||
// BlocklistProvider blocklistProvider,
|
||||
// IIgnoredDownloadsService ignoredDownloadsService,
|
||||
// DownloadServiceFactory downloadServiceFactory
|
||||
// ) : base(
|
||||
// logger, cache, messageBus,
|
||||
// arrClientFactory, arrArrQueueIterator, downloadServiceFactory
|
||||
// )
|
||||
// {
|
||||
// _blocklistProvider = blocklistProvider;
|
||||
// _ignoredDownloadsService = ignoredDownloadsService;
|
||||
//
|
||||
// _config = configManager.GetConfiguration<ContentBlockerConfig>();
|
||||
// _downloadClientConfig = configManager.GetConfiguration<DownloadClientConfig>();
|
||||
// _sonarrConfig = configManager.GetConfiguration<SonarrConfig>();
|
||||
// _radarrConfig = configManager.GetConfiguration<RadarrConfig>();
|
||||
// _lidarrConfig = configManager.GetConfiguration<LidarrConfig>();
|
||||
// }
|
||||
//
|
||||
// public override async Task ExecuteAsync()
|
||||
// {
|
||||
// if (_downloadClientConfig.Clients.Count is 0)
|
||||
// {
|
||||
// _logger.LogWarning("No download clients configured");
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// bool blocklistIsConfigured = _config.Sonarr.Enabled && !string.IsNullOrEmpty(_config.Sonarr.Path) ||
|
||||
// _config.Radarr.Enabled && !string.IsNullOrEmpty(_config.Radarr.Path) ||
|
||||
// _config.Lidarr.Enabled && !string.IsNullOrEmpty(_config.Lidarr.Path);
|
||||
//
|
||||
// if (!blocklistIsConfigured)
|
||||
// {
|
||||
// _logger.LogWarning("no blocklist is configured");
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// await _blocklistProvider.LoadBlocklistsAsync();
|
||||
// await base.ExecuteAsync();
|
||||
// }
|
||||
//
|
||||
// protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
|
||||
// {
|
||||
// IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsService.GetIgnoredDownloadsAsync();
|
||||
//
|
||||
// using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());
|
||||
//
|
||||
// IArrClient arrClient = _arrClientFactory.GetClient(instanceType);
|
||||
// BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
// ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
// ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
//
|
||||
// await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
// {
|
||||
// var groups = items
|
||||
// .GroupBy(x => x.DownloadId)
|
||||
// .ToList();
|
||||
//
|
||||
// foreach (var group in groups)
|
||||
// {
|
||||
// if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// QueueRecord record = group.First();
|
||||
//
|
||||
// if (record.Protocol is not "torrent")
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// if (string.IsNullOrEmpty(record.DownloadId))
|
||||
// {
|
||||
// _logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
|
||||
// {
|
||||
// _logger.LogInformation("skip | {title} | ignored", record.Title);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// _logger.LogTrace("processing | {name}", record.Title);
|
||||
//
|
||||
// string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
|
||||
//
|
||||
// if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
// {
|
||||
// _logger.LogDebug("skip | already marked for removal | {title}", record.Title);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// _logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
// bool found = false;
|
||||
//
|
||||
// foreach (var downloadService in _downloadServices)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// BlockFilesResult result = await downloadService
|
||||
// .BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes, ignoredDownloads);
|
||||
//
|
||||
// if (!result.Found)
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// found = true;
|
||||
//
|
||||
// if (!result.ShouldRemove)
|
||||
// {
|
||||
// break;
|
||||
// }
|
||||
//
|
||||
// _logger.LogDebug("all files are marked as unwanted | {hash}", record.Title);
|
||||
//
|
||||
// bool removeFromClient = true;
|
||||
//
|
||||
// if (result.IsPrivate && !_config.DeletePrivate)
|
||||
// {
|
||||
// removeFromClient = false;
|
||||
// }
|
||||
//
|
||||
// await PublishQueueItemRemoveRequest(
|
||||
// downloadRemovalKey,
|
||||
// instanceType,
|
||||
// instance,
|
||||
// record,
|
||||
// group.Count() > 1,
|
||||
// removeFromClient,
|
||||
// DeleteReason.AllFilesBlocked
|
||||
// );
|
||||
//
|
||||
// break;
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// _logger.LogError(
|
||||
// ex,
|
||||
// "Error blocking unwanted files for {hash} with download client",
|
||||
// record.DownloadId);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (!found)
|
||||
// {
|
||||
// _logger.LogWarning("skip | download not found {title}", record.Title);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.CustomDataTypes;
|
||||
using Common.Exceptions;
|
||||
using Data.Enums;
|
||||
using Data.Models.Deluge.Response;
|
||||
using Infrastructure.Events;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Infrastructure.Configuration;
|
||||
@@ -23,7 +13,7 @@ using Infrastructure.Http;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public class DelugeService : DownloadService, IDelugeService
|
||||
public partial class DelugeService : DownloadService, IDelugeService
|
||||
{
|
||||
private DelugeClient? _client;
|
||||
|
||||
@@ -36,11 +26,12 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService,
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider
|
||||
) : base(
|
||||
logger, configManager, cache,
|
||||
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher
|
||||
httpClientProvider, eventPublisher, blocklistProvider
|
||||
)
|
||||
{
|
||||
// Client will be initialized when Initialize() is called with a specific client configuration
|
||||
@@ -95,476 +86,6 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Deluge client is not initialized");
|
||||
}
|
||||
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
DelugeContents? contents = null;
|
||||
DownloadCheckResult result = new();
|
||||
|
||||
DownloadStatus? download = await _client.GetTorrentStatus(hash);
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Found = true;
|
||||
result.IsPrivate = download.Private;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
|
||||
bool shouldRemove = contents?.Contents?.Count > 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (_, file) =>
|
||||
{
|
||||
if (file.Priority > 0)
|
||||
{
|
||||
shouldRemove = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
DownloadStatus? download = await _client.GetTorrentStatus(hash);
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Mark as processed since we found the download
|
||||
result.Found = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = download.Private;
|
||||
|
||||
if (_contentBlockerConfig.IgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
DelugeContents? contents = null;
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
if (contents is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
Dictionary<int, int> priorities = [];
|
||||
bool hasPriorityUpdates = false;
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
totalFiles++;
|
||||
int priority = file.Priority;
|
||||
|
||||
if (file.Priority is 0)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes))
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
priority = 0;
|
||||
hasPriorityUpdates = true;
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
}
|
||||
|
||||
priorities.Add(file.Index, priority);
|
||||
});
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
List<int> sortedPriorities = priorities
|
||||
.OrderBy(x => x.Key)
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
result.ShouldRemove = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
return (await _client.GetStatusForAllTorrents())
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<DownloadStatus>()
|
||||
.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<DownloadStatus>()
|
||||
.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 (DownloadStatus download in downloads)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.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;
|
||||
}
|
||||
|
||||
if (!_downloadCleanerConfig.DeletePrivate && download.Private)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTime);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, 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, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
IReadOnlyList<string> existingLabels = await _client.GetLabels();
|
||||
|
||||
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (DownloadStatus download in downloads.Cast<DownloadStatus>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
DelugeContents? contents = null;
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(download.Hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
ProcessFiles(contents?.Contents, (_, file) =>
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadLocation, file.Path).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Label, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.Label = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[DryRunSafeguard]
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
await _client.DeleteTorrents([hash]);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected async Task CreateLabel(string name)
|
||||
{
|
||||
await _client.CreateLabel(name);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
|
||||
{
|
||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetTorrentLabel(hash, newLabel);
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(DownloadStatus status)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(status);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(status);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(DownloadStatus download)
|
||||
{
|
||||
if (_queueCleanerConfig.SlowMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.SlowIgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Size > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime);
|
||||
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(DownloadStatus status)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && status.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(status.Hash!, status.TotalDone);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(status.Hash!, status.Name!, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
|
||||
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Data.Enums;
|
||||
using Data.Models.Deluge.Response;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public partial class DelugeService
|
||||
{
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
return (await _client.GetStatusForAllTorrents())
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<DownloadStatus>()
|
||||
.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<DownloadStatus>()
|
||||
.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 (DownloadStatus download in downloads)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.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;
|
||||
}
|
||||
|
||||
if (!_downloadCleanerConfig.DeletePrivate && download.Private)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTime);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, 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, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
IReadOnlyList<string> existingLabels = await _client.GetLabels();
|
||||
|
||||
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (DownloadStatus download in downloads.Cast<DownloadStatus>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
DelugeContents? contents = null;
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(download.Hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
ProcessFiles(contents?.Contents, (_, file) =>
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadLocation, file.Path).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
long hardlinkCount = _hardLinkFileService
|
||||
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Label, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.Label = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[DryRunSafeguard]
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
await _client.DeleteTorrents([hash]);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected async Task CreateLabel(string name)
|
||||
{
|
||||
await _client.CreateLabel(name);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetTorrentLabel(hash, newLabel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.CustomDataTypes;
|
||||
using Data.Enums;
|
||||
using Data.Models.Deluge.Response;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public partial class DelugeService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Deluge client is not initialized");
|
||||
}
|
||||
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
DelugeContents? contents = null;
|
||||
DownloadCheckResult result = new();
|
||||
|
||||
DownloadStatus? download = await _client.GetTorrentStatus(hash);
|
||||
|
||||
if (download?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Found = true;
|
||||
result.IsPrivate = download.Private;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
|
||||
bool shouldRemove = contents?.Contents?.Count > 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (_, file) =>
|
||||
{
|
||||
if (file.Priority > 0)
|
||||
{
|
||||
shouldRemove = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, download.Private, contents.Contents);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> BlockUnwantedFilesAsync(
|
||||
DownloadStatus download,
|
||||
bool isPrivate,
|
||||
Dictionary<string, DelugeFileOrDirectory>? files
|
||||
)
|
||||
{
|
||||
if (!_queueCleanerConfig.ContentBlocker.Enabled)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.ContentBlocker.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip unwanted files check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find files for {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Dictionary<int, int> priorities = [];
|
||||
bool hasPriorityUpdates = false;
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
ProcessFiles(files, (name, file) =>
|
||||
{
|
||||
totalFiles++;
|
||||
int priority = file.Priority;
|
||||
|
||||
if (file.Priority is 0)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes))
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
priority = 0;
|
||||
hasPriorityUpdates = true;
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
}
|
||||
|
||||
priorities.Add(file.Index, priority);
|
||||
});
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
_logger.LogTrace("marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
|
||||
|
||||
List<int> sortedPriorities = priorities
|
||||
.OrderBy(x => x.Key)
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
return (true, DeleteReason.AllFilesBlocked);
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, download.Hash, sortedPriorities);
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
|
||||
{
|
||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(
|
||||
DownloadStatus status,
|
||||
bool isPrivate,
|
||||
Dictionary<string, DelugeFileOrDirectory>? files
|
||||
)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await BlockUnwantedFilesAsync(status, isPrivate, files);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = await CheckIfSlow(status);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(status);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(DownloadStatus download)
|
||||
{
|
||||
if (_queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Slow.IgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("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);
|
||||
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(DownloadStatus status)
|
||||
{
|
||||
if (_queueCleanerConfig.Stalled.MaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Stalled.IgnorePrivate && status.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(status.Hash!, status.TotalDone);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(status.Hash!, status.Name!, _queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
@@ -17,9 +16,9 @@ using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
@@ -35,15 +34,16 @@ public abstract class DownloadService : IDownloadService
|
||||
protected readonly IHardLinkFileService _hardLinkFileService;
|
||||
protected readonly IDynamicHttpClientProvider _httpClientProvider;
|
||||
protected readonly EventPublisher _eventPublisher;
|
||||
protected readonly BlocklistProvider _blocklistProvider;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly ContentBlockerConfig _contentBlockerConfig;
|
||||
protected readonly DownloadCleanerConfig _downloadCleanerConfig;
|
||||
protected HttpClient? _httpClient;
|
||||
|
||||
|
||||
// Client-specific configuration
|
||||
protected ClientConfig _clientConfig;
|
||||
|
||||
// HTTP client for this service
|
||||
protected HttpClient? _httpClient;
|
||||
|
||||
protected DownloadService(
|
||||
ILogger<DownloadService> logger,
|
||||
@@ -54,7 +54,8 @@ public abstract class DownloadService : IDownloadService
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService,
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -66,6 +67,7 @@ public abstract class DownloadService : IDownloadService
|
||||
_hardLinkFileService = hardLinkFileService;
|
||||
_httpClientProvider = httpClientProvider;
|
||||
_eventPublisher = eventPublisher;
|
||||
_blocklistProvider = blocklistProvider;
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||
|
||||
@@ -73,7 +75,6 @@ public abstract class DownloadService : IDownloadService
|
||||
_clientConfig = new ClientConfig();
|
||||
|
||||
_queueCleanerConfig = _configManager.GetConfiguration<QueueCleanerConfig>();
|
||||
_contentBlockerConfig = _configManager.GetConfiguration<ContentBlockerConfig>();
|
||||
_downloadCleanerConfig = _configManager.GetConfiguration<DownloadCleanerConfig>();
|
||||
}
|
||||
|
||||
@@ -99,13 +100,8 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
public abstract Task LoginAsync();
|
||||
|
||||
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads);
|
||||
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
|
||||
IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task DeleteDownload(string hash);
|
||||
@@ -130,7 +126,7 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
|
||||
{
|
||||
if (!_queueCleanerConfig.StalledResetStrikesOnProgress)
|
||||
if (!_queueCleanerConfig.Stalled.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -148,7 +144,7 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
protected void ResetSlowSpeedStrikesOnProgress(string downloadName, string hash)
|
||||
{
|
||||
if (_queueCleanerConfig.SlowResetStrikesOnProgress)
|
||||
if (_queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -166,7 +162,7 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
protected void ResetSlowTimeStrikesOnProgress(string downloadName, string hash)
|
||||
{
|
||||
if (_queueCleanerConfig.SlowResetStrikesOnProgress)
|
||||
if (_queueCleanerConfig.Slow.ResetStrikesOnProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +192,7 @@ public abstract class DownloadService : IDownloadService
|
||||
_logger.LogTrace("slow speed | {speed}/s | {name}", currentSpeed.ToString(), downloadName);
|
||||
|
||||
bool shouldRemove = await _striker
|
||||
.StrikeAndCheckLimit(downloadHash, downloadName, _queueCleanerConfig.SlowMaxStrikes, StrikeType.SlowSpeed);
|
||||
.StrikeAndCheckLimit(downloadHash, downloadName, _queueCleanerConfig.Slow.MaxStrikes, StrikeType.SlowSpeed);
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
@@ -213,7 +209,7 @@ public abstract class DownloadService : IDownloadService
|
||||
_logger.LogTrace("slow estimated time | {time} | {name}", currentTime.ToString(), downloadName);
|
||||
|
||||
bool shouldRemove = await _striker
|
||||
.StrikeAndCheckLimit(downloadHash, downloadName, _queueCleanerConfig.SlowMaxStrikes, StrikeType.SlowTime);
|
||||
.StrikeAndCheckLimit(downloadHash, downloadName, _queueCleanerConfig.Slow.MaxStrikes, StrikeType.SlowTime);
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
|
||||
@@ -28,30 +28,30 @@ public sealed class DownloadServiceFactory
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a download service using the specified client ID
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client ID to create a service for</param>
|
||||
/// <returns>An implementation of IDownloadService or null if the client is not available</returns>
|
||||
public IDownloadService? GetDownloadService(Guid clientId)
|
||||
{
|
||||
var config = _configManager.GetConfiguration<DownloadClientConfig>();
|
||||
var clientConfig = config.GetClientConfig(clientId);
|
||||
|
||||
if (clientConfig == null)
|
||||
{
|
||||
_logger.LogWarning("No download client configuration found for ID {clientId}", clientId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!clientConfig.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Download client {clientId} is disabled", clientId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetDownloadService(clientConfig);
|
||||
}
|
||||
// /// <summary>
|
||||
// /// Creates a download service using the specified client ID
|
||||
// /// </summary>
|
||||
// /// <param name="clientId">The client ID to create a service for</param>
|
||||
// /// <returns>An implementation of IDownloadService or null if the client is not available</returns>
|
||||
// public IDownloadService? GetDownloadService(Guid clientId)
|
||||
// {
|
||||
// var config = _configManager.GetConfiguration<DownloadClientConfig>();
|
||||
// var clientConfig = config.GetClientConfig(clientId);
|
||||
//
|
||||
// if (clientConfig == null)
|
||||
// {
|
||||
// _logger.LogWarning("No download client configuration found for ID {clientId}", clientId);
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// if (!clientConfig.Enabled)
|
||||
// {
|
||||
// _logger.LogWarning("Download client {clientId} is disabled", clientId);
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// return GetDownloadService(clientConfig);
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a download service using the specified client configuration
|
||||
@@ -83,7 +83,6 @@ public sealed class DownloadServiceFactory
|
||||
/// <returns>An implementation of IDownloadService</returns>
|
||||
private T CreateClientService<T>(ClientConfig clientConfig) where T : IDownloadService
|
||||
{
|
||||
// TODO
|
||||
var service = _serviceProvider.GetRequiredService<T>();
|
||||
service.Initialize(clientConfig);
|
||||
return service;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Data.Enums;
|
||||
using Infrastructure.Interceptors;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
@@ -28,23 +30,8 @@ public interface IDownloadService : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="hash">The download hash.</param>
|
||||
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
|
||||
public Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Blocks unwanted files from being fully downloaded.
|
||||
/// </summary>
|
||||
/// <param name="hash">The torrent hash.</param>
|
||||
/// <param name="blocklistType">The <see cref="BlocklistType"/>.</param>
|
||||
/// <param name="patterns">The patterns to test the files against.</param>
|
||||
/// <param name="regexes">The regexes to test the files against.</param>
|
||||
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
|
||||
/// <returns>True if all files have been blocked; otherwise false.</returns>
|
||||
public Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes,
|
||||
IReadOnlyList<string> ignoredDownloads
|
||||
);
|
||||
public Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
|
||||
IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all seeding downloads.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
@@ -24,7 +23,7 @@ using Infrastructure.Events;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
public class QBitService : DownloadService, IQBitService
|
||||
public partial class QBitService : DownloadService, IQBitService
|
||||
{
|
||||
protected QBittorrentClient? _client;
|
||||
|
||||
@@ -38,10 +37,11 @@ public class QBitService : DownloadService, IQBitService
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService,
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider
|
||||
) : base(
|
||||
logger, configManager, cache, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher
|
||||
httpClientProvider, eventPublisher, blocklistProvider
|
||||
)
|
||||
{
|
||||
// Client will be initialized when Initialize() is called with a specific client configuration
|
||||
@@ -53,12 +53,6 @@ public class QBitService : DownloadService, IQBitService
|
||||
// Initialize base service first
|
||||
base.Initialize(clientConfig);
|
||||
|
||||
// Ensure client type is correct
|
||||
if (clientConfig.Type != Common.Enums.DownloadClientType.QBittorrent)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot initialize QBitService with client type {clientConfig.Type}");
|
||||
}
|
||||
|
||||
// Create QBittorrent client
|
||||
_client = new QBittorrentClient(_httpClient, clientConfig.Url);
|
||||
|
||||
@@ -90,589 +84,7 @@ public class QBitService : DownloadService, IQBitService
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
DownloadCheckResult result = new();
|
||||
TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Found = true;
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||
{
|
||||
result.ShouldRemove = true;
|
||||
|
||||
// if all files were blocked by qBittorrent
|
||||
if (download is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if all files are unwanted
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(
|
||||
string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes,
|
||||
IReadOnlyList<string> ignoredDownloads
|
||||
)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
BlockFilesResult result = new();
|
||||
TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Mark as processed since we found the download
|
||||
result.Found = true;
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
if (_contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
List<int> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Name);
|
||||
unwantedFiles.Add(file.Index.Value);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
result.ShouldRemove = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (int fileIndex in unwantedFiles)
|
||||
{
|
||||
await _dryRunInterceptor.InterceptAsync(SkipFile, hash, fileIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Seeding });
|
||||
return torrentList?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Where(x =>
|
||||
{
|
||||
if (_downloadCleanerConfig.UnlinkedUseTag)
|
||||
{
|
||||
return !x.Tags.Any(tag => tag.Equals(_downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean,
|
||||
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_downloadCleanerConfig.DeletePrivate)
|
||||
{
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
if (isPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, 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, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, Category>? existingCategories = await _client.GetCategoriesAsync();
|
||||
|
||||
if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(download.Hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
bool hasHardlinks = false;
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
_logger.LogDebug("skip | file index is null for {name}", download.Name);
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
if (_downloadCleanerConfig.UnlinkedUseTag)
|
||||
{
|
||||
_logger.LogInformation("tag added for {name}", download.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory, _downloadCleanerConfig.UnlinkedUseTag);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[DryRunSafeguard]
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
await _client.DeleteAsync([hash], deleteDownloadedData: true);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected async Task CreateCategory(string name)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
await _client.AddCategoryAsync(name);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task SkipFile(string hash, int fileIndex)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeCategory(string hash, string newCategory)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
if (_downloadCleanerConfig.UnlinkedUseTag)
|
||||
{
|
||||
await _client.AddTorrentTagAsync([hash], newCategory);
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.SetTorrentCategoryAsync([hash], newCategory);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client?.Dispose();
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo 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(TorrentInfo download, bool isPrivate)
|
||||
{
|
||||
if (_queueCleanerConfig.SlowMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.SlowIgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Size > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime);
|
||||
SmartTimeSpan currentTime = new SmartTimeSpan(download.EstimatedTime ?? TimeSpan.Zero);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.Hash,
|
||||
download.Name,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo torrent, bool isPrivate)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0 && _queueCleanerConfig.DownloadingMetadataMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledMaxStrikes > 0 && torrent.State is TorrentState.StalledDownload)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetStalledStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.DownloadingMetadataMaxStrikes > 0 && torrent.State is not TorrentState.StalledDownload)
|
||||
{
|
||||
return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.DownloadingMetadataMaxStrikes, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
|
||||
}
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
|
||||
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
|
||||
{
|
||||
if (_client == null)
|
||||
@@ -684,4 +96,10 @@ public class QBitService : DownloadService, IQBitService
|
||||
.Where(x => x.Url.Contains("**"))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client?.Dispose();
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Data.Enums;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
public partial class QBitService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Seeding });
|
||||
return torrentList?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Where(x =>
|
||||
{
|
||||
if (_downloadCleanerConfig.UnlinkedUseTag)
|
||||
{
|
||||
return !x.Tags.Any(tag => tag.Equals(_downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean,
|
||||
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_downloadCleanerConfig.DeletePrivate)
|
||||
{
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
if (isPrivate)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, 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, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, Category>? existingCategories = await _client.GetCategoriesAsync();
|
||||
|
||||
if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(download.Hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
bool hasHardlinks = false;
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
_logger.LogDebug("skip | file index is null for {name}", download.Name);
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
if (_downloadCleanerConfig.UnlinkedUseTag)
|
||||
{
|
||||
_logger.LogInformation("tag added for {name}", download.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory, _downloadCleanerConfig.UnlinkedUseTag);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[DryRunSafeguard]
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
await _client.DeleteAsync([hash], deleteDownloadedData: true);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected async Task CreateCategory(string name)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
await _client.AddCategoryAsync(name);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeCategory(string hash, string newCategory)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
if (_downloadCleanerConfig.UnlinkedUseTag)
|
||||
{
|
||||
await _client.AddTorrentTagAsync([hash], newCategory);
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.SetTorrentCategoryAsync([hash], newCategory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.CustomDataTypes;
|
||||
using Data.Enums;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
public partial class QBitService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("QBittorrent client is not initialized");
|
||||
}
|
||||
|
||||
DownloadCheckResult result = new();
|
||||
TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Found = true;
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
|
||||
|
||||
if (torrentProperties is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
|
||||
bool.TryParse(dictValue?.ToString(), out bool boolValue)
|
||||
&& boolValue;
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||
{
|
||||
result.ShouldRemove = true;
|
||||
|
||||
// if all files were blocked by qBittorrent
|
||||
if (download is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if all files are unwanted
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate, files);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> BlockUnwantedFilesAsync(
|
||||
TorrentInfo torrent,
|
||||
bool isPrivate,
|
||||
IReadOnlyList<TorrentContent>? files
|
||||
)
|
||||
{
|
||||
if (!_queueCleanerConfig.ContentBlocker.Enabled)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.ContentBlocker.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip unwanted files check | download is private | {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find files for {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
List<int> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
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);
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Name);
|
||||
unwantedFiles.Add(file.Index.Value);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
return (true, DeleteReason.AllFilesBlocked);
|
||||
}
|
||||
|
||||
_logger.LogTrace("marking {count} unwanted files as skipped for {name}", unwantedFiles.Count, torrent.Name);
|
||||
|
||||
foreach (int fileIndex in unwantedFiles)
|
||||
{
|
||||
await _dryRunInterceptor.InterceptAsync(MarkFileAsSkipped, torrent.Hash, fileIndex);
|
||||
}
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task MarkFileAsSkipped(string hash, int fileIndex)
|
||||
{
|
||||
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(
|
||||
TorrentInfo torrent,
|
||||
bool isPrivate,
|
||||
IReadOnlyList<TorrentContent>? files
|
||||
)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await BlockUnwantedFilesAsync(torrent, isPrivate, files);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = await CheckIfSlow(torrent, isPrivate);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(torrent, isPrivate);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download, bool isPrivate)
|
||||
{
|
||||
if (_queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.DownloadSpeed <= 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Slow.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("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);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = _queueCleanerConfig.Slow.MinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.Slow.MaxTime);
|
||||
SmartTimeSpan currentTime = new SmartTimeSpan(download.EstimatedTime ?? TimeSpan.Zero);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.Hash,
|
||||
download.Name,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo torrent, bool isPrivate)
|
||||
{
|
||||
if (_queueCleanerConfig.Stalled.MaxStrikes is 0 && _queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Stalled.MaxStrikes > 0 && torrent.State is TorrentState.StalledDownload)
|
||||
{
|
||||
if (_queueCleanerConfig.Stalled.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetStalledStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
|
||||
|
||||
return (
|
||||
await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.Stalled.MaxStrikes,
|
||||
StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes > 0 && torrent.State is not TorrentState.StalledDownload)
|
||||
{
|
||||
return (
|
||||
await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes,
|
||||
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
|
||||
}
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
@@ -26,7 +25,7 @@ using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
|
||||
public class TransmissionService : DownloadService, ITransmissionService
|
||||
public partial class TransmissionService : DownloadService, ITransmissionService
|
||||
{
|
||||
private Client? _client;
|
||||
|
||||
@@ -58,11 +57,12 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService,
|
||||
IDynamicHttpClientProvider httpClientProvider,
|
||||
EventPublisher eventPublisher
|
||||
EventPublisher eventPublisher,
|
||||
BlocklistProvider blocklistProvider
|
||||
) : base(
|
||||
logger, configManager, cache,
|
||||
filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
|
||||
httpClientProvider, eventPublisher
|
||||
httpClientProvider, eventPublisher, blocklistProvider
|
||||
)
|
||||
{
|
||||
// Client will be initialized when Initialize() is called with a specific client configuration
|
||||
@@ -118,482 +118,15 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Transmission client is not initialized");
|
||||
}
|
||||
|
||||
DownloadCheckResult result = new();
|
||||
TorrentInfo? download = await GetTorrentAsync(hash);
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Found = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool shouldRemove = download.FileStats?.Length > 0;
|
||||
result.IsPrivate = download.IsPrivate ?? false;
|
||||
|
||||
foreach (TransmissionTorrentFileStats? stats in download.FileStats ?? [])
|
||||
{
|
||||
if (!stats.Wanted.HasValue)
|
||||
{
|
||||
// if any files stats are missing, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
|
||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||
{
|
||||
// if any files are wanted, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesBlocked;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
|
||||
BlocklistType blocklistType,
|
||||
ConcurrentBag<string> patterns,
|
||||
ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
TorrentInfo? download = await GetTorrentAsync(hash);
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (download?.FileStats is null || download.Files is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Mark as processed since we found the download
|
||||
result.Found = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
if (_contentBlockerConfig.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<long> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
if (download.FileStats?[i].Wanted == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (!download.FileStats[i].Wanted.Value)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", download.Files[i].Name);
|
||||
unwantedFiles.Add(i);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
result.ShouldRemove = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task<List<object>?> GetSeedingDownloads() =>
|
||||
(await _client.TorrentGetAsync(Fields))
|
||||
?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => x.Status is 5 or 6)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories)
|
||||
{
|
||||
return downloads
|
||||
?
|
||||
.Cast<TorrentInfo>()
|
||||
.Where(x => categories
|
||||
.Any(cat => cat.Name.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase))
|
||||
)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||
{
|
||||
return downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.GetCategory(), 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 (TorrentInfo download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x =>
|
||||
{
|
||||
if (download.DownloadDir is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir))
|
||||
.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase);
|
||||
});
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_downloadCleanerConfig.DeletePrivate && download.IsPrivate is true)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SecondsSeeding ?? 0);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.uploadRatio ?? 0, seedingTime, category);
|
||||
|
||||
if (!result.ShouldClean)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, download.Id);
|
||||
|
||||
_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.uploadRatio ?? 0, seedingTime, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads.Cast<TorrentInfo>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString) || string.IsNullOrEmpty(download.Name) || download.DownloadDir == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
if (download.Files is null || download.FileStats is null)
|
||||
{
|
||||
_logger.LogDebug("skip | download has no files | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
TransmissionTorrentFiles file = download.Files[i];
|
||||
TransmissionTorrentFileStats stats = download.FileStats[i];
|
||||
|
||||
if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, file.Name).Split(['\\', '/']));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
string currentCategory = download.GetCategory();
|
||||
string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, _downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/']));
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, download.Id, newLocation);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.DownloadDir = newLocation;
|
||||
}
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation)
|
||||
{
|
||||
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
|
||||
}
|
||||
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.TorrentRemoveAsync([torrent.Id], true);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client = null;
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task RemoveDownloadAsync(long downloadId)
|
||||
{
|
||||
await _client.TorrentRemoveAsync([downloadId], true);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles)
|
||||
{
|
||||
await _client.TorrentSetAsync(new TorrentSettings
|
||||
{
|
||||
Ids = [downloadId],
|
||||
FilesUnwanted = unwantedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo torrent)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(torrent);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download)
|
||||
{
|
||||
if (_queueCleanerConfig.SlowMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.RateDownload <= 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.SlowIgnorePrivate && download.IsPrivate is true)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.TotalSize > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.RateDownload ?? long.MaxValue);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime);
|
||||
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta ?? 0);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.HashString!,
|
||||
download.Name!,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo download)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.RateDownload > 0 || download.Eta > 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && (download.IsPrivate ?? false))
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(download.HashString!, download.DownloadedEver ?? 0);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(download.HashString!, download.Name!, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
|
||||
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Transmission client is not initialized");
|
||||
}
|
||||
|
||||
return (await _client.TorrentGetAsync(Fields, hash))
|
||||
?.Torrents
|
||||
?.FirstOrDefault();
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Data.Enums;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
|
||||
public partial class TransmissionService
|
||||
{
|
||||
public override async Task<List<object>?> GetSeedingDownloads() =>
|
||||
(await _client.TorrentGetAsync(Fields))
|
||||
?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => x.Status is 5 or 6)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories)
|
||||
{
|
||||
return downloads
|
||||
?
|
||||
.Cast<TorrentInfo>()
|
||||
.Where(x => categories
|
||||
.Any(cat => cat.Name.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase))
|
||||
)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||
{
|
||||
return downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.GetCategory(), 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 (TorrentInfo download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x =>
|
||||
{
|
||||
if (download.DownloadDir is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir))
|
||||
.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase);
|
||||
});
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_downloadCleanerConfig.DeletePrivate && download.IsPrivate is true)
|
||||
{
|
||||
_logger.LogDebug("skip | download is private | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SecondsSeeding ?? 0);
|
||||
SeedingCheckResult result = ShouldCleanDownload(download.uploadRatio ?? 0, seedingTime, category);
|
||||
|
||||
if (!result.ShouldClean)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, download.Id);
|
||||
|
||||
_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.uploadRatio ?? 0, seedingTime, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads.Cast<TorrentInfo>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString) || string.IsNullOrEmpty(download.Name) || download.DownloadDir == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
if (download.Files is null || download.FileStats is null)
|
||||
{
|
||||
_logger.LogDebug("skip | download has no files | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
TransmissionTorrentFiles file = download.Files[i];
|
||||
TransmissionTorrentFileStats stats = download.FileStats[i];
|
||||
|
||||
if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, file.Name).Split(['\\', '/']));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
string currentCategory = download.GetCategory();
|
||||
string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, _downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/']));
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, download.Id, newLocation);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _eventPublisher.PublishCategoryChanged(currentCategory, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.DownloadDir = newLocation;
|
||||
}
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation)
|
||||
{
|
||||
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
|
||||
}
|
||||
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.TorrentRemoveAsync([torrent.Id], true);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task RemoveDownloadAsync(long downloadId)
|
||||
{
|
||||
await _client.TorrentRemoveAsync([downloadId], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Attributes;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.CustomDataTypes;
|
||||
using Data.Enums;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Transmission.API.RPC.Arguments;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
|
||||
public partial class TransmissionService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Transmission client is not initialized");
|
||||
}
|
||||
|
||||
DownloadCheckResult result = new();
|
||||
TorrentInfo? download = await GetTorrentAsync(hash);
|
||||
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Found = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool shouldRemove = download.FileStats?.Length > 0;
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
|
||||
foreach (TransmissionTorrentFileStats stats in download.FileStats ?? [])
|
||||
{
|
||||
if (!stats.Wanted.HasValue)
|
||||
{
|
||||
// if any files stats are missing, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
|
||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||
{
|
||||
// if any files are wanted, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemove)
|
||||
{
|
||||
// remove if all files are unwanted
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.AllFilesSkipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
// remove if download is stuck
|
||||
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, isPrivate);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> BlockUnwantedFilesAsync(
|
||||
TorrentInfo download,
|
||||
bool isPrivate
|
||||
)
|
||||
{
|
||||
if (!_queueCleanerConfig.ContentBlocker.Enabled)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.ContentBlocker.IgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip unwanted files check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Files is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find files for {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
List<long> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
if (download.FileStats?[i].Wanted == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (!download.FileStats[i].Wanted.Value)
|
||||
{
|
||||
totalUnwantedFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", download.Files[i].Name);
|
||||
unwantedFiles.Add(i);
|
||||
totalUnwantedFiles++;
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (totalUnwantedFiles == totalFiles)
|
||||
{
|
||||
// Skip marking files as unwanted. The download will be removed completely.
|
||||
return (true, DeleteReason.AllFilesBlocked);
|
||||
}
|
||||
|
||||
_logger.LogTrace("marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles)
|
||||
{
|
||||
await _client.TorrentSetAsync(new TorrentSettings
|
||||
{
|
||||
Ids = [downloadId],
|
||||
FilesUnwanted = unwantedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo download, bool isPrivate)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await BlockUnwantedFilesAsync(download, isPrivate);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = await CheckIfSlow(download);
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await CheckIfStuck(download);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download)
|
||||
{
|
||||
if (_queueCleanerConfig.Slow.MaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.RateDownload <= 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Slow.IgnorePrivate && download.IsPrivate is true)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.TotalSize > (_queueCleanerConfig.Slow.IgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
|
||||
{
|
||||
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ByteSize minSpeed = _queueCleanerConfig.Slow.MinSpeedByteSize;
|
||||
ByteSize currentSpeed = new ByteSize(download.RateDownload ?? long.MaxValue);
|
||||
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.Slow.MaxTime);
|
||||
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta ?? 0);
|
||||
|
||||
return await CheckIfSlow(
|
||||
download.HashString!,
|
||||
download.Name!,
|
||||
minSpeed,
|
||||
currentSpeed,
|
||||
maxTime,
|
||||
currentTime
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo download)
|
||||
{
|
||||
if (_queueCleanerConfig.Stalled.MaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (download.RateDownload > 0 || download.Eta > 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.Stalled.IgnorePrivate && (download.IsPrivate ?? false))
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", download.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
ResetStalledStrikesOnProgress(download.HashString!, download.DownloadedEver ?? 0);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(download.HashString!, download.Name!, _queueCleanerConfig.Stalled.MaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LogContext = Serilog.Context.LogContext;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using BlocklistSettings = Common.Configuration.QueueCleaner.BlocklistSettings;
|
||||
|
||||
namespace Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
@@ -28,6 +32,7 @@ public sealed class QueueCleaner : GenericHandler
|
||||
private readonly QueueCleanerConfig _config;
|
||||
private readonly IIgnoredDownloadsService _ignoredDownloadsService;
|
||||
private readonly IDownloadClientFactory _downloadClientFactory;
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public QueueCleaner(
|
||||
ILogger<QueueCleaner> logger,
|
||||
@@ -38,7 +43,8 @@ public sealed class QueueCleaner : GenericHandler
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
IIgnoredDownloadsService ignoredDownloadsService,
|
||||
IDownloadClientFactory downloadClientFactory
|
||||
IDownloadClientFactory downloadClientFactory,
|
||||
BlocklistProvider blocklistProvider
|
||||
) : base(
|
||||
logger, cache, messageBus,
|
||||
arrClientFactory, arrArrQueueIterator, downloadServiceFactory
|
||||
@@ -46,7 +52,8 @@ public sealed class QueueCleaner : GenericHandler
|
||||
{
|
||||
_ignoredDownloadsService = ignoredDownloadsService;
|
||||
_downloadClientFactory = downloadClientFactory;
|
||||
|
||||
_blocklistProvider = blocklistProvider;
|
||||
|
||||
_config = configManager.GetConfiguration<QueueCleanerConfig>();
|
||||
_downloadClientConfig = configManager.GetConfiguration<DownloadClientConfig>();
|
||||
_sonarrConfig = configManager.GetConfiguration<SonarrConfig>();
|
||||
@@ -54,6 +61,26 @@ public sealed class QueueCleaner : GenericHandler
|
||||
_lidarrConfig = configManager.GetConfiguration<LidarrConfig>();
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
if (_downloadClientConfig.Clients.Count is 0)
|
||||
{
|
||||
_logger.LogWarning("No download clients configured");
|
||||
return;
|
||||
}
|
||||
|
||||
bool blocklistIsConfigured = _sonarrConfig.Enabled && !string.IsNullOrEmpty(_config.ContentBlocker.Sonarr.BlocklistPath) ||
|
||||
_radarrConfig.Enabled && !string.IsNullOrEmpty(_config.ContentBlocker.Radarr.BlocklistPath) ||
|
||||
_lidarrConfig.Enabled && !string.IsNullOrEmpty(_config.ContentBlocker.Lidarr.BlocklistPath);
|
||||
|
||||
if (_config.ContentBlocker.Enabled && blocklistIsConfigured)
|
||||
{
|
||||
await _blocklistProvider.LoadBlocklistsAsync();
|
||||
}
|
||||
|
||||
await base.ExecuteAsync();
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
|
||||
{
|
||||
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsService.GetIgnoredDownloadsAsync();
|
||||
@@ -112,41 +139,42 @@ public sealed class QueueCleaner : GenericHandler
|
||||
if (_downloadClientConfig.Clients.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("skip | no download clients configured | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check each download client for the download item
|
||||
foreach (var downloadService in _downloadClientFactory.GetAllEnabledClients())
|
||||
else
|
||||
{
|
||||
try
|
||||
// Check each download client for the download item
|
||||
foreach (var downloadService in _downloadClientFactory.GetAllEnabledClients())
|
||||
{
|
||||
// stalled download check
|
||||
var result = await downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads);
|
||||
if (result.Found)
|
||||
try
|
||||
{
|
||||
downloadCheckResult = result;
|
||||
// Add client ID to context for tracking
|
||||
ContextProvider.Set("ClientId", downloadService.GetClientId());
|
||||
break;
|
||||
// stalled download check
|
||||
DownloadCheckResult result = await downloadService
|
||||
.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads);
|
||||
|
||||
if (result.Found)
|
||||
{
|
||||
downloadCheckResult = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking download {id} with download client {clientId}",
|
||||
record.DownloadId, downloadService.GetClientId());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking download {id} with download client {clientId}",
|
||||
record.DownloadId, downloadService.GetClientId());
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadCheckResult.Found)
|
||||
{
|
||||
_logger.LogWarning("skip | download not found {title}", record.Title);
|
||||
if (!downloadCheckResult.Found)
|
||||
{
|
||||
_logger.LogWarning("skip | download not found {title}", record.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failed import check
|
||||
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.FailedImportMaxStrikes);
|
||||
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, _config.FailedImport.MaxStrikes);
|
||||
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
|
||||
|
||||
|
||||
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)
|
||||
{
|
||||
_logger.LogInformation("skip | {title}", record.Title);
|
||||
@@ -159,14 +187,20 @@ public sealed class QueueCleaner : GenericHandler
|
||||
{
|
||||
bool isStalledWithoutPruneFlag =
|
||||
downloadCheckResult.DeleteReason is DeleteReason.Stalled &&
|
||||
!_config.StalledDeletePrivate;
|
||||
!_config.Stalled.DeletePrivate;
|
||||
|
||||
bool isSlowWithoutPruneFlag =
|
||||
downloadCheckResult.DeleteReason is DeleteReason.SlowSpeed or DeleteReason.SlowTime &&
|
||||
!_config.SlowDeletePrivate;
|
||||
!_config.Slow.DeletePrivate;
|
||||
|
||||
bool isContentBlockerWithoutPruneFlag =
|
||||
deleteReason is DeleteReason.AllFilesBlocked &&
|
||||
!_config.ContentBlocker.DeletePrivate;
|
||||
|
||||
bool shouldKeepDueToDeleteRules = downloadCheckResult.ShouldRemove && (isStalledWithoutPruneFlag || isSlowWithoutPruneFlag);
|
||||
bool shouldKeepDueToImportRules = shouldRemoveFromArr && !_config.FailedImportDeletePrivate;
|
||||
bool shouldKeepDueToDeleteRules = downloadCheckResult.ShouldRemove &&
|
||||
(isStalledWithoutPruneFlag || isSlowWithoutPruneFlag || isContentBlockerWithoutPruneFlag);
|
||||
|
||||
bool shouldKeepDueToImportRules = shouldRemoveFromArr && !_config.FailedImport.DeletePrivate;
|
||||
|
||||
if (shouldKeepDueToDeleteRules || shouldKeepDueToImportRules)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { QueueCleanerConfigStore } from "./queue-cleaner-config.store";
|
||||
import { QueueCleanerConfig, ScheduleUnit } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { QueueCleanerConfig, ScheduleUnit, BlocklistType, FailedImportConfig, StalledConfig, SlowConfig, ContentBlockerConfig } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { SettingsCardComponent } from "../components/settings-card/settings-card.component";
|
||||
import { ByteSizeInputComponent } from "../../shared/components/byte-size-input/byte-size-input.component";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SelectButtonModule } from "primeng/selectbutton";
|
||||
import { ChipsModule } from "primeng/chips";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { MessageService } from "primeng/api";
|
||||
import { DropdownModule } from "primeng/dropdown";
|
||||
|
||||
@Component({
|
||||
selector: "app-queue-cleaner-settings",
|
||||
@@ -25,7 +26,6 @@ import { MessageService } from "primeng/api";
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
SettingsCardComponent,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
CheckboxModule,
|
||||
@@ -36,6 +36,7 @@ import { MessageService } from "primeng/api";
|
||||
ChipsModule,
|
||||
ToastModule,
|
||||
ByteSizeInputComponent,
|
||||
DropdownModule,
|
||||
],
|
||||
providers: [QueueCleanerConfigStore, MessageService],
|
||||
templateUrl: "./queue-cleaner-settings.component.html",
|
||||
@@ -80,29 +81,52 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
runSequentially: [{value: false, disabled: true}],
|
||||
ignoredDownloadsPath: [{value: "", disabled: true}],
|
||||
|
||||
// Failed Import settings
|
||||
failedImportMaxStrikes: [0, [Validators.min(0)]],
|
||||
failedImportIgnorePrivate: [{value: false, disabled: true}],
|
||||
failedImportDeletePrivate: [{value: false, disabled: true}],
|
||||
failedImportIgnorePatterns: [{value: [], disabled: true}],
|
||||
// Failed Import settings - nested group
|
||||
failedImport: this.formBuilder.group({
|
||||
maxStrikes: [0, [Validators.min(0)]],
|
||||
ignorePrivate: [{value: false, disabled: true}],
|
||||
deletePrivate: [{value: false, disabled: true}],
|
||||
ignorePatterns: [{value: [], disabled: true}],
|
||||
}),
|
||||
|
||||
// Stalled settings
|
||||
stalledMaxStrikes: [0, [Validators.min(0)]],
|
||||
stalledResetStrikesOnProgress: [{value: false, disabled: true}],
|
||||
stalledIgnorePrivate: [{value: false, disabled: true}],
|
||||
stalledDeletePrivate: [{value: false, disabled: true}],
|
||||
// Stalled settings - nested group
|
||||
stalled: this.formBuilder.group({
|
||||
maxStrikes: [0, [Validators.min(0)]],
|
||||
resetStrikesOnProgress: [{value: false, disabled: true}],
|
||||
ignorePrivate: [{value: false, disabled: true}],
|
||||
deletePrivate: [{value: false, disabled: true}],
|
||||
downloadingMetadataMaxStrikes: [0, [Validators.min(0)]],
|
||||
}),
|
||||
|
||||
// Downloading Metadata settings
|
||||
downloadingMetadataMaxStrikes: [0, [Validators.min(0)]],
|
||||
|
||||
// Slow Download settings
|
||||
slowMaxStrikes: [0, [Validators.min(0)]],
|
||||
slowResetStrikesOnProgress: [{value: false, disabled: true}],
|
||||
slowIgnorePrivate: [{value: false, disabled: true}],
|
||||
slowDeletePrivate: [{value: false, disabled: true}],
|
||||
slowMinSpeed: [{value: "", disabled: true}],
|
||||
slowMaxTime: [{value: 0, disabled: true}],
|
||||
slowIgnoreAboveSize: [{value: "", disabled: true}],
|
||||
// Slow Download settings - nested group
|
||||
slow: this.formBuilder.group({
|
||||
maxStrikes: [0, [Validators.min(0)]],
|
||||
resetStrikesOnProgress: [{value: false, disabled: true}],
|
||||
ignorePrivate: [{value: false, disabled: true}],
|
||||
deletePrivate: [{value: false, disabled: true}],
|
||||
minSpeed: [{value: "", disabled: true}],
|
||||
maxTime: [{value: 0, disabled: true}],
|
||||
ignoreAboveSize: [{value: "", disabled: true}],
|
||||
}),
|
||||
|
||||
// Content Blocker settings - nested group
|
||||
contentBlocker: this.formBuilder.group({
|
||||
enabled: [{value: false, disabled: true}],
|
||||
ignorePrivate: [{value: false, disabled: true}],
|
||||
deletePrivate: [{value: false, disabled: true}],
|
||||
sonarrBlocklist: this.formBuilder.group({
|
||||
path: [{value: "", disabled: true}],
|
||||
type: [{value: BlocklistType.Blacklist, disabled: true}],
|
||||
}),
|
||||
radarrBlocklist: this.formBuilder.group({
|
||||
path: [{value: "", disabled: true}],
|
||||
type: [{value: BlocklistType.Blacklist, disabled: true}],
|
||||
}),
|
||||
lidarrBlocklist: this.formBuilder.group({
|
||||
path: [{value: "", disabled: true}],
|
||||
type: [{value: BlocklistType.Blacklist, disabled: true}],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Set up form control value change subscriptions to manage dependent control states
|
||||
@@ -112,44 +136,97 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
effect(() => {
|
||||
const config = this.queueCleanerConfig();
|
||||
if (config) {
|
||||
// Update the form with the current configuration
|
||||
this.queueCleanerForm.patchValue({
|
||||
// Build form values for the nested configuration structure
|
||||
const formValues: any = {
|
||||
enabled: config.enabled,
|
||||
runSequentially: config.runSequentially,
|
||||
ignoredDownloadsPath: config.ignoredDownloadsPath,
|
||||
|
||||
// Failed Import settings
|
||||
failedImportMaxStrikes: config.failedImportMaxStrikes,
|
||||
failedImportIgnorePrivate: config.failedImportIgnorePrivate,
|
||||
failedImportDeletePrivate: config.failedImportDeletePrivate,
|
||||
failedImportIgnorePatterns: config.failedImportIgnorePatterns,
|
||||
|
||||
// Stalled settings
|
||||
stalledMaxStrikes: config.stalledMaxStrikes,
|
||||
stalledResetStrikesOnProgress: config.stalledResetStrikesOnProgress,
|
||||
stalledIgnorePrivate: config.stalledIgnorePrivate,
|
||||
stalledDeletePrivate: config.stalledDeletePrivate,
|
||||
|
||||
// Downloading Metadata settings
|
||||
downloadingMetadataMaxStrikes: config.downloadingMetadataMaxStrikes,
|
||||
|
||||
// Slow Download settings
|
||||
slowMaxStrikes: config.slowMaxStrikes,
|
||||
slowResetStrikesOnProgress: config.slowResetStrikesOnProgress,
|
||||
slowIgnorePrivate: config.slowIgnorePrivate,
|
||||
slowDeletePrivate: config.slowDeletePrivate,
|
||||
slowMinSpeed: config.slowMinSpeed,
|
||||
slowMaxTime: config.slowMaxTime,
|
||||
slowIgnoreAboveSize: config.slowIgnoreAboveSize,
|
||||
});
|
||||
|
||||
// Update job schedule if it exists
|
||||
};
|
||||
|
||||
// Add jobSchedule if it exists
|
||||
if (config.jobSchedule) {
|
||||
this.queueCleanerForm.get("jobSchedule")?.patchValue({
|
||||
formValues.jobSchedule = {
|
||||
every: config.jobSchedule.every,
|
||||
type: config.jobSchedule.type,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Add Failed Import settings
|
||||
if (config.failedImport) {
|
||||
formValues.failedImport = {
|
||||
maxStrikes: config.failedImport.maxStrikes,
|
||||
ignorePrivate: config.failedImport.ignorePrivate,
|
||||
deletePrivate: config.failedImport.deletePrivate,
|
||||
ignorePatterns: config.failedImport.ignorePatterns,
|
||||
};
|
||||
} else if (config.failedImportMaxStrikes !== undefined) {
|
||||
// Fall back to legacy flat properties if nested ones don't exist
|
||||
formValues.failedImport = {
|
||||
maxStrikes: config.failedImportMaxStrikes,
|
||||
ignorePrivate: config.failedImportIgnorePrivate,
|
||||
deletePrivate: config.failedImportDeletePrivate,
|
||||
ignorePatterns: config.failedImportIgnorePatterns || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Add Stalled settings
|
||||
if (config.stalled) {
|
||||
formValues.stalled = {
|
||||
maxStrikes: config.stalled.maxStrikes,
|
||||
resetStrikesOnProgress: config.stalled.resetStrikesOnProgress,
|
||||
ignorePrivate: config.stalled.ignorePrivate,
|
||||
deletePrivate: config.stalled.deletePrivate,
|
||||
downloadingMetadataMaxStrikes: config.stalled.downloadingMetadataMaxStrikes,
|
||||
};
|
||||
} else if (config.stalledMaxStrikes !== undefined) {
|
||||
// Fall back to legacy flat properties if nested ones don't exist
|
||||
formValues.stalled = {
|
||||
maxStrikes: config.stalledMaxStrikes,
|
||||
resetStrikesOnProgress: config.stalledResetStrikesOnProgress,
|
||||
ignorePrivate: config.stalledIgnorePrivate,
|
||||
deletePrivate: config.stalledDeletePrivate,
|
||||
downloadingMetadataMaxStrikes: config.downloadingMetadataMaxStrikes || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Add Slow Download settings
|
||||
if (config.slow) {
|
||||
formValues.slow = {
|
||||
maxStrikes: config.slow.maxStrikes,
|
||||
resetStrikesOnProgress: config.slow.resetStrikesOnProgress,
|
||||
ignorePrivate: config.slow.ignorePrivate,
|
||||
deletePrivate: config.slow.deletePrivate,
|
||||
minSpeed: config.slow.minSpeed,
|
||||
maxTime: config.slow.maxTime,
|
||||
ignoreAboveSize: config.slow.ignoreAboveSize,
|
||||
};
|
||||
} else if (config.slowMaxStrikes !== undefined) {
|
||||
// Fall back to legacy flat properties if nested ones don't exist
|
||||
formValues.slow = {
|
||||
maxStrikes: config.slowMaxStrikes,
|
||||
resetStrikesOnProgress: config.slowResetStrikesOnProgress,
|
||||
ignorePrivate: config.slowIgnorePrivate,
|
||||
deletePrivate: config.slowDeletePrivate,
|
||||
minSpeed: config.slowMinSpeed || "",
|
||||
maxTime: config.slowMaxTime || 0,
|
||||
ignoreAboveSize: config.slowIgnoreAboveSize || "",
|
||||
};
|
||||
}
|
||||
|
||||
// Add Content Blocker settings
|
||||
if (config.contentBlocker) {
|
||||
formValues.contentBlocker = {
|
||||
enabled: config.contentBlocker.enabled,
|
||||
ignorePrivate: config.contentBlocker.ignorePrivate,
|
||||
deletePrivate: config.contentBlocker.deletePrivate,
|
||||
sonarrBlocklist: config.contentBlocker.sonarrBlocklist,
|
||||
radarrBlocklist: config.contentBlocker.radarrBlocklist,
|
||||
lidarrBlocklist: config.contentBlocker.lidarrBlocklist,
|
||||
};
|
||||
}
|
||||
|
||||
// Update the form with the current configuration
|
||||
this.queueCleanerForm.patchValue(formValues);
|
||||
|
||||
// Update form control disabled states based on the configuration
|
||||
this.updateFormControlDisabledStates(config);
|
||||
@@ -188,93 +265,84 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
* Set up listeners for form control value changes to manage dependent control states
|
||||
*/
|
||||
private setupFormValueChangeListeners(): void {
|
||||
// Listen for changes on the 'enabled' control
|
||||
this.queueCleanerForm.get('enabled')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((enabled: boolean) => {
|
||||
this.updateMainControlsState(enabled);
|
||||
});
|
||||
// Listen for changes to the enabled control
|
||||
this.queueCleanerForm.get('enabled')?.valueChanges.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(enabled => {
|
||||
this.updateMainControlsState(enabled);
|
||||
});
|
||||
|
||||
// Listen for changes on 'failedImportMaxStrikes' control
|
||||
this.queueCleanerForm.get('failedImportMaxStrikes')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((strikes: number) => {
|
||||
this.updateFailedImportDependentControls(strikes);
|
||||
});
|
||||
// Failed import settings
|
||||
this.queueCleanerForm.get('failedImport.maxStrikes')?.valueChanges.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(strikes => {
|
||||
this.updateFailedImportDependentControls(strikes);
|
||||
});
|
||||
|
||||
// Listen for changes on 'stalledMaxStrikes' control
|
||||
this.queueCleanerForm.get('stalledMaxStrikes')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((strikes: number) => {
|
||||
this.updateStalledDependentControls(strikes);
|
||||
});
|
||||
// Stalled settings
|
||||
this.queueCleanerForm.get('stalled.maxStrikes')?.valueChanges.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(strikes => {
|
||||
this.updateStalledDependentControls(strikes);
|
||||
});
|
||||
|
||||
// Listen for changes on 'slowMaxStrikes' control
|
||||
this.queueCleanerForm.get('slowMaxStrikes')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((strikes: number) => {
|
||||
this.updateSlowDependentControls(strikes);
|
||||
});
|
||||
// Slow downloads settings
|
||||
this.queueCleanerForm.get('slow.maxStrikes')?.valueChanges.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(strikes => {
|
||||
this.updateSlowDependentControls(strikes);
|
||||
});
|
||||
|
||||
// Content blocker settings
|
||||
this.queueCleanerForm.get('enabled')?.valueChanges.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(enabled => {
|
||||
if (enabled) {
|
||||
this.queueCleanerForm.get('contentBlocker.enabled')?.enable();
|
||||
} else {
|
||||
this.queueCleanerForm.get('contentBlocker.enabled')?.disable();
|
||||
}
|
||||
});
|
||||
|
||||
// Update content blocker dependent controls when enabled changes
|
||||
this.queueCleanerForm.get('contentBlocker.enabled')?.valueChanges.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(enabled => {
|
||||
this.updateContentBlockerDependentControls(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update form control disabled states based on the configuration
|
||||
*/
|
||||
private updateFormControlDisabledStates(config: QueueCleanerConfig): void {
|
||||
const enabled = config.enabled;
|
||||
const options = { onlySelf: true };
|
||||
// Update main form controls based on the 'enabled' state
|
||||
this.updateMainControlsState(config.enabled);
|
||||
|
||||
// Job schedule
|
||||
if (enabled) {
|
||||
this.queueCleanerForm.get("jobSchedule")?.enable(options);
|
||||
this.queueCleanerForm.get("runSequentially")?.enable(options);
|
||||
this.queueCleanerForm.get("ignoredDownloadsPath")?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get("jobSchedule")?.disable(options);
|
||||
this.queueCleanerForm.get("runSequentially")?.disable(options);
|
||||
this.queueCleanerForm.get("ignoredDownloadsPath")?.disable(options);
|
||||
// Check if failed import strikes are set and update dependent controls
|
||||
if (config.failedImport?.maxStrikes !== undefined) {
|
||||
this.updateFailedImportDependentControls(config.failedImport.maxStrikes);
|
||||
} else if (config.failedImportMaxStrikes !== undefined) { // Fall back to legacy property if needed
|
||||
this.updateFailedImportDependentControls(config.failedImportMaxStrikes);
|
||||
}
|
||||
|
||||
// Failed Import settings
|
||||
const failedImportEnabled = enabled && config.failedImportMaxStrikes >= 3;
|
||||
if (failedImportEnabled) {
|
||||
this.queueCleanerForm.get("failedImportIgnorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("failedImportDeletePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("failedImportIgnorePatterns")?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get("failedImportIgnorePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("failedImportDeletePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("failedImportIgnorePatterns")?.disable(options);
|
||||
// Check if stalled strikes are set and update dependent controls
|
||||
if (config.stalled?.maxStrikes !== undefined) {
|
||||
this.updateStalledDependentControls(config.stalled.maxStrikes);
|
||||
} else if (config.stalledMaxStrikes !== undefined) { // Fall back to legacy property if needed
|
||||
this.updateStalledDependentControls(config.stalledMaxStrikes);
|
||||
}
|
||||
|
||||
// Stalled settings
|
||||
const stalledEnabled = enabled && config.stalledMaxStrikes >= 3;
|
||||
if (stalledEnabled) {
|
||||
this.queueCleanerForm.get("stalledResetStrikesOnProgress")?.enable(options);
|
||||
this.queueCleanerForm.get("stalledIgnorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("stalledDeletePrivate")?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get("stalledResetStrikesOnProgress")?.disable(options);
|
||||
this.queueCleanerForm.get("stalledIgnorePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("stalledDeletePrivate")?.disable(options);
|
||||
// Check if slow download strikes are set and update dependent controls
|
||||
if (config.slow?.maxStrikes !== undefined) {
|
||||
this.updateSlowDependentControls(config.slow.maxStrikes);
|
||||
} else if (config.slowMaxStrikes !== undefined) { // Fall back to legacy property if needed
|
||||
this.updateSlowDependentControls(config.slowMaxStrikes);
|
||||
}
|
||||
|
||||
// Slow Download settings
|
||||
const slowEnabled = enabled && config.slowMaxStrikes >= 3;
|
||||
if (slowEnabled) {
|
||||
this.queueCleanerForm.get("slowResetStrikesOnProgress")?.enable(options);
|
||||
this.queueCleanerForm.get("slowIgnorePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("slowDeletePrivate")?.enable(options);
|
||||
this.queueCleanerForm.get("slowMinSpeed")?.enable(options);
|
||||
this.queueCleanerForm.get("slowMaxTime")?.enable(options);
|
||||
this.queueCleanerForm.get("slowIgnoreAboveSize")?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get("slowResetStrikesOnProgress")?.disable(options);
|
||||
this.queueCleanerForm.get("slowIgnorePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("slowDeletePrivate")?.disable(options);
|
||||
this.queueCleanerForm.get("slowMinSpeed")?.disable(options);
|
||||
this.queueCleanerForm.get("slowMaxTime")?.disable(options);
|
||||
this.queueCleanerForm.get("slowIgnoreAboveSize")?.disable(options);
|
||||
|
||||
// Check if content blocker is enabled and update dependent controls
|
||||
if (config.contentBlocker?.enabled !== undefined) {
|
||||
this.updateContentBlockerDependentControls(config.contentBlocker.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,30 +351,43 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
*/
|
||||
private updateMainControlsState(enabled: boolean): void {
|
||||
const options = { onlySelf: true };
|
||||
|
||||
|
||||
if (enabled) {
|
||||
this.queueCleanerForm.get('jobSchedule')?.enable(options);
|
||||
this.queueCleanerForm.get('runSequentially')?.enable(options);
|
||||
this.queueCleanerForm.get('ignoredDownloadsPath')?.enable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('enabled')?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get('jobSchedule')?.disable(options);
|
||||
this.queueCleanerForm.get('runSequentially')?.disable(options);
|
||||
this.queueCleanerForm.get('ignoredDownloadsPath')?.disable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('enabled')?.disable(options);
|
||||
this.updateContentBlockerDependentControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of Failed Import dependent controls based on the 'failedImportMaxStrikes' value
|
||||
* Update the state of Failed Import dependent controls based on the 'maxStrikes' value
|
||||
*/
|
||||
private updateFailedImportDependentControls(strikes: number): void {
|
||||
const enable = strikes >= 3;
|
||||
const options = { onlySelf: true };
|
||||
|
||||
if (enable) {
|
||||
this.queueCleanerForm.get('failedImport')?.get('ignorePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('failedImport')?.get('deletePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('failedImport')?.get('ignorePatterns')?.enable(options);
|
||||
|
||||
// Also enable legacy controls if they exist for backward compatibility
|
||||
this.queueCleanerForm.get('failedImportIgnorePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('failedImportDeletePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('failedImportIgnorePatterns')?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get('failedImport')?.get('ignorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('failedImport')?.get('deletePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('failedImport')?.get('ignorePatterns')?.disable(options);
|
||||
|
||||
// Also disable legacy controls if they exist
|
||||
this.queueCleanerForm.get('failedImportIgnorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('failedImportDeletePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('failedImportIgnorePatterns')?.disable(options);
|
||||
@@ -314,17 +395,27 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of Stalled dependent controls based on the 'stalledMaxStrikes' value
|
||||
* Update the state of Stalled dependent controls based on the 'maxStrikes' value
|
||||
*/
|
||||
private updateStalledDependentControls(strikes: number): void {
|
||||
const enable = strikes >= 3;
|
||||
const options = { onlySelf: true };
|
||||
|
||||
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);
|
||||
|
||||
// Also enable legacy controls if they exist for backward compatibility
|
||||
this.queueCleanerForm.get('stalledResetStrikesOnProgress')?.enable(options);
|
||||
this.queueCleanerForm.get('stalledIgnorePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('stalledDeletePrivate')?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get('stalled')?.get('resetStrikesOnProgress')?.disable(options);
|
||||
this.queueCleanerForm.get('stalled')?.get('ignorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('stalled')?.get('deletePrivate')?.disable(options);
|
||||
|
||||
// Also disable legacy controls if they exist
|
||||
this.queueCleanerForm.get('stalledResetStrikesOnProgress')?.disable(options);
|
||||
this.queueCleanerForm.get('stalledIgnorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('stalledDeletePrivate')?.disable(options);
|
||||
@@ -332,13 +423,21 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of Slow Download dependent controls based on the 'slowMaxStrikes' value
|
||||
* Update the state of Slow Download dependent controls based on the 'maxStrikes' value
|
||||
*/
|
||||
private updateSlowDependentControls(strikes: number): void {
|
||||
const enable = strikes >= 3;
|
||||
const options = { onlySelf: true };
|
||||
|
||||
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);
|
||||
|
||||
// Also enable legacy controls if they exist for backward compatibility
|
||||
this.queueCleanerForm.get('slowResetStrikesOnProgress')?.enable(options);
|
||||
this.queueCleanerForm.get('slowIgnorePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('slowDeletePrivate')?.enable(options);
|
||||
@@ -346,6 +445,14 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
this.queueCleanerForm.get('slowMaxTime')?.enable(options);
|
||||
this.queueCleanerForm.get('slowIgnoreAboveSize')?.enable(options);
|
||||
} else {
|
||||
this.queueCleanerForm.get('slow')?.get('resetStrikesOnProgress')?.disable(options);
|
||||
this.queueCleanerForm.get('slow')?.get('ignorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('slow')?.get('deletePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('slow')?.get('minSpeed')?.disable(options);
|
||||
this.queueCleanerForm.get('slow')?.get('maxTime')?.disable(options);
|
||||
this.queueCleanerForm.get('slow')?.get('ignoreAboveSize')?.disable(options);
|
||||
|
||||
// Also disable legacy controls if they exist
|
||||
this.queueCleanerForm.get('slowResetStrikesOnProgress')?.disable(options);
|
||||
this.queueCleanerForm.get('slowIgnorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('slowDeletePrivate')?.disable(options);
|
||||
@@ -354,6 +461,47 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
this.queueCleanerForm.get('slowIgnoreAboveSize')?.disable(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of Content Blocker dependent controls based on the 'enabled' value
|
||||
*/
|
||||
private updateContentBlockerDependentControls(enabled: boolean): void {
|
||||
const options = { onlySelf: true };
|
||||
|
||||
if (enabled) {
|
||||
// Enable blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('ignorePrivate')?.enable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('deletePrivate')?.enable(options);
|
||||
|
||||
// Enable Sonarr blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('sonarrBlocklist')?.get('path')?.enable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('sonarrBlocklist')?.get('type')?.enable(options);
|
||||
|
||||
// Enable Radarr blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('radarrBlocklist')?.get('path')?.enable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('radarrBlocklist')?.get('type')?.enable(options);
|
||||
|
||||
// Enable Lidarr blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('lidarrBlocklist')?.get('path')?.enable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('lidarrBlocklist')?.get('type')?.enable(options);
|
||||
} else {
|
||||
// Disable blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('ignorePrivate')?.disable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('deletePrivate')?.disable(options);
|
||||
|
||||
// Disable Sonarr blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('sonarrBlocklist')?.get('path')?.disable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('sonarrBlocklist')?.get('type')?.disable(options);
|
||||
|
||||
// Disable Radarr blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('radarrBlocklist')?.get('path')?.disable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('radarrBlocklist')?.get('type')?.disable(options);
|
||||
|
||||
// Disable Lidarr blocklist settings
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('lidarrBlocklist')?.get('path')?.disable(options);
|
||||
this.queueCleanerForm.get('contentBlocker')?.get('lidarrBlocklist')?.get('type')?.disable(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the queue cleaner configuration
|
||||
@@ -374,7 +522,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
// Get the form values
|
||||
const formValues = this.queueCleanerForm.getRawValue(); // Get values including disabled fields
|
||||
|
||||
// Build the configuration object
|
||||
// Build the configuration object with nested structure
|
||||
const config: QueueCleanerConfig = {
|
||||
enabled: formValues.enabled,
|
||||
// The cronExpression will be generated from the jobSchedule when saving
|
||||
@@ -383,29 +531,69 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
runSequentially: formValues.runSequentially,
|
||||
ignoredDownloadsPath: formValues.ignoredDownloadsPath || "",
|
||||
|
||||
// Failed Import settings
|
||||
failedImportMaxStrikes: formValues.failedImportMaxStrikes,
|
||||
failedImportIgnorePrivate: formValues.failedImportIgnorePrivate,
|
||||
failedImportDeletePrivate: formValues.failedImportDeletePrivate,
|
||||
failedImportIgnorePatterns: formValues.failedImportIgnorePatterns || [],
|
||||
// Legacy flat properties for backward compatibility
|
||||
failedImportMaxStrikes: formValues.failedImport?.maxStrikes,
|
||||
failedImportIgnorePrivate: formValues.failedImport?.ignorePrivate,
|
||||
failedImportDeletePrivate: formValues.failedImport?.deletePrivate,
|
||||
failedImportIgnorePatterns: formValues.failedImport?.ignorePatterns || [],
|
||||
|
||||
stalledMaxStrikes: formValues.stalled?.maxStrikes,
|
||||
stalledResetStrikesOnProgress: formValues.stalled?.resetStrikesOnProgress,
|
||||
stalledIgnorePrivate: formValues.stalled?.ignorePrivate,
|
||||
stalledDeletePrivate: formValues.stalled?.deletePrivate,
|
||||
downloadingMetadataMaxStrikes: formValues.stalled?.downloadingMetadataMaxStrikes,
|
||||
|
||||
slowMaxStrikes: formValues.slow?.maxStrikes,
|
||||
slowResetStrikesOnProgress: formValues.slow?.resetStrikesOnProgress,
|
||||
slowIgnorePrivate: formValues.slow?.ignorePrivate,
|
||||
slowDeletePrivate: formValues.slow?.deletePrivate,
|
||||
slowMinSpeed: formValues.slow?.minSpeed || "",
|
||||
slowMaxTime: formValues.slow?.maxTime,
|
||||
slowIgnoreAboveSize: formValues.slow?.ignoreAboveSize || "",
|
||||
|
||||
// Stalled settings
|
||||
stalledMaxStrikes: formValues.stalledMaxStrikes,
|
||||
stalledResetStrikesOnProgress: formValues.stalledResetStrikesOnProgress,
|
||||
stalledIgnorePrivate: formValues.stalledIgnorePrivate,
|
||||
stalledDeletePrivate: formValues.stalledDeletePrivate,
|
||||
|
||||
// Downloading Metadata settings
|
||||
downloadingMetadataMaxStrikes: formValues.downloadingMetadataMaxStrikes,
|
||||
|
||||
// Slow Download settings
|
||||
slowMaxStrikes: formValues.slowMaxStrikes,
|
||||
slowResetStrikesOnProgress: formValues.slowResetStrikesOnProgress,
|
||||
slowIgnorePrivate: formValues.slowIgnorePrivate,
|
||||
slowDeletePrivate: formValues.slowDeletePrivate,
|
||||
slowMinSpeed: formValues.slowMinSpeed || "",
|
||||
slowMaxTime: formValues.slowMaxTime,
|
||||
slowIgnoreAboveSize: formValues.slowIgnoreAboveSize || "",
|
||||
// Nested configuration objects
|
||||
failedImport: {
|
||||
maxStrikes: formValues.failedImport?.maxStrikes || 0,
|
||||
ignorePrivate: formValues.failedImport?.ignorePrivate || false,
|
||||
deletePrivate: formValues.failedImport?.deletePrivate || false,
|
||||
ignorePatterns: formValues.failedImport?.ignorePatterns || [],
|
||||
},
|
||||
|
||||
stalled: {
|
||||
maxStrikes: formValues.stalled?.maxStrikes || 0,
|
||||
resetStrikesOnProgress: formValues.stalled?.resetStrikesOnProgress || false,
|
||||
ignorePrivate: formValues.stalled?.ignorePrivate || false,
|
||||
deletePrivate: formValues.stalled?.deletePrivate || false,
|
||||
downloadingMetadataMaxStrikes: formValues.stalled?.downloadingMetadataMaxStrikes || 0,
|
||||
},
|
||||
|
||||
slow: {
|
||||
maxStrikes: formValues.slow?.maxStrikes || 0,
|
||||
resetStrikesOnProgress: formValues.slow?.resetStrikesOnProgress || false,
|
||||
ignorePrivate: formValues.slow?.ignorePrivate || false,
|
||||
deletePrivate: formValues.slow?.deletePrivate || false,
|
||||
minSpeed: formValues.slow?.minSpeed || "",
|
||||
maxTime: formValues.slow?.maxTime || 0,
|
||||
ignoreAboveSize: formValues.slow?.ignoreAboveSize || "",
|
||||
},
|
||||
|
||||
contentBlocker: {
|
||||
enabled: formValues.contentBlocker?.enabled || false,
|
||||
ignorePrivate: formValues.contentBlocker?.ignorePrivate || false,
|
||||
deletePrivate: formValues.contentBlocker?.deletePrivate || false,
|
||||
sonarrBlocklist: formValues.contentBlocker?.sonarrBlocklist || {
|
||||
path: "",
|
||||
type: BlocklistType.Blacklist
|
||||
},
|
||||
radarrBlocklist: formValues.contentBlocker?.radarrBlocklist || {
|
||||
path: "",
|
||||
type: BlocklistType.Blacklist
|
||||
},
|
||||
lidarrBlocklist: formValues.contentBlocker?.lidarrBlocklist || {
|
||||
path: "",
|
||||
type: BlocklistType.Blacklist
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Save the configuration
|
||||
@@ -425,29 +613,52 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
runSequentially: false,
|
||||
ignoredDownloadsPath: "",
|
||||
|
||||
// Failed Import settings
|
||||
failedImportMaxStrikes: 0,
|
||||
failedImportIgnorePrivate: false,
|
||||
failedImportDeletePrivate: false,
|
||||
failedImportIgnorePatterns: [],
|
||||
// Failed Import settings (nested)
|
||||
failedImport: {
|
||||
maxStrikes: 0,
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
ignorePatterns: [],
|
||||
},
|
||||
|
||||
// Stalled settings
|
||||
stalledMaxStrikes: 0,
|
||||
stalledResetStrikesOnProgress: false,
|
||||
stalledIgnorePrivate: false,
|
||||
stalledDeletePrivate: false,
|
||||
// Stalled settings (nested)
|
||||
stalled: {
|
||||
maxStrikes: 0,
|
||||
resetStrikesOnProgress: false,
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
downloadingMetadataMaxStrikes: 0,
|
||||
},
|
||||
|
||||
// Downloading Metadata settings
|
||||
downloadingMetadataMaxStrikes: 0,
|
||||
// Slow Download settings (nested)
|
||||
slow: {
|
||||
maxStrikes: 0,
|
||||
resetStrikesOnProgress: false,
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
minSpeed: "",
|
||||
maxTime: 0,
|
||||
ignoreAboveSize: "",
|
||||
},
|
||||
|
||||
// Slow Download settings
|
||||
slowMaxStrikes: 0,
|
||||
slowResetStrikesOnProgress: false,
|
||||
slowIgnorePrivate: false,
|
||||
slowDeletePrivate: false,
|
||||
slowMinSpeed: "",
|
||||
slowMaxTime: 0,
|
||||
slowIgnoreAboveSize: "",
|
||||
// Content Blocker settings (nested)
|
||||
contentBlocker: {
|
||||
enabled: false,
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
sonarrBlocklist: {
|
||||
path: "",
|
||||
type: BlocklistType.Blacklist,
|
||||
},
|
||||
radarrBlocklist: {
|
||||
path: "",
|
||||
type: BlocklistType.Blacklist,
|
||||
},
|
||||
lidarrBlocklist: {
|
||||
path: "",
|
||||
type: BlocklistType.Blacklist,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Manually update control states after reset
|
||||
@@ -455,6 +666,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy {
|
||||
this.updateFailedImportDependentControls(0);
|
||||
this.updateStalledDependentControls(0);
|
||||
this.updateSlowDependentControls(0);
|
||||
this.updateContentBlockerDependentControls(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,52 @@ export interface JobSchedule {
|
||||
type: ScheduleUnit;
|
||||
}
|
||||
|
||||
export enum BlocklistType {
|
||||
Blacklist = 'Blacklist',
|
||||
Whitelist = 'Whitelist'
|
||||
}
|
||||
|
||||
export interface BlocklistSettings {
|
||||
path: string;
|
||||
type: BlocklistType;
|
||||
}
|
||||
|
||||
// Nested configuration interfaces
|
||||
export interface FailedImportConfig {
|
||||
maxStrikes: number;
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
ignorePatterns: string[];
|
||||
}
|
||||
|
||||
export interface StalledConfig {
|
||||
maxStrikes: number;
|
||||
resetStrikesOnProgress: boolean;
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
downloadingMetadataMaxStrikes: number;
|
||||
}
|
||||
|
||||
export interface SlowConfig {
|
||||
maxStrikes: number;
|
||||
resetStrikesOnProgress: boolean;
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
minSpeed: string;
|
||||
maxTime: number;
|
||||
ignoreAboveSize: string;
|
||||
}
|
||||
|
||||
export interface ContentBlockerConfig {
|
||||
enabled: boolean;
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
sonarrBlocklist?: BlocklistSettings;
|
||||
radarrBlocklist?: BlocklistSettings;
|
||||
lidarrBlocklist?: BlocklistSettings;
|
||||
customBlocklists?: BlocklistSettings[];
|
||||
}
|
||||
|
||||
export interface QueueCleanerConfig {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
@@ -16,27 +62,28 @@ export interface QueueCleanerConfig {
|
||||
runSequentially: boolean;
|
||||
ignoredDownloadsPath: string;
|
||||
|
||||
// Failed Import settings
|
||||
failedImportMaxStrikes: number;
|
||||
failedImportIgnorePrivate: boolean;
|
||||
failedImportDeletePrivate: boolean;
|
||||
failedImportIgnorePatterns: string[];
|
||||
// Nested configurations
|
||||
failedImport: FailedImportConfig;
|
||||
stalled: StalledConfig;
|
||||
slow: SlowConfig;
|
||||
contentBlocker: ContentBlockerConfig;
|
||||
|
||||
// Stalled settings
|
||||
stalledMaxStrikes: number;
|
||||
stalledResetStrikesOnProgress: boolean;
|
||||
stalledIgnorePrivate: boolean;
|
||||
stalledDeletePrivate: boolean;
|
||||
|
||||
// Downloading Metadata settings
|
||||
downloadingMetadataMaxStrikes: number;
|
||||
|
||||
// Slow Download settings
|
||||
slowMaxStrikes: number;
|
||||
slowResetStrikesOnProgress: boolean;
|
||||
slowIgnorePrivate: boolean;
|
||||
slowDeletePrivate: boolean;
|
||||
slowMinSpeed: string;
|
||||
slowMaxTime: number;
|
||||
slowIgnoreAboveSize: string;
|
||||
// Legacy flat properties for backward compatibility
|
||||
// These will be mapped to/from the nested structure
|
||||
failedImportMaxStrikes?: number;
|
||||
failedImportIgnorePrivate?: boolean;
|
||||
failedImportDeletePrivate?: boolean;
|
||||
failedImportIgnorePatterns?: string[];
|
||||
stalledMaxStrikes?: number;
|
||||
stalledResetStrikesOnProgress?: boolean;
|
||||
stalledIgnorePrivate?: boolean;
|
||||
stalledDeletePrivate?: boolean;
|
||||
downloadingMetadataMaxStrikes?: number;
|
||||
slowMaxStrikes?: number;
|
||||
slowResetStrikesOnProgress?: boolean;
|
||||
slowIgnorePrivate?: boolean;
|
||||
slowDeletePrivate?: boolean;
|
||||
slowMinSpeed?: string;
|
||||
slowMaxTime?: number;
|
||||
slowIgnoreAboveSize?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user