Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b766300ff | |||
| 2d3ff04172 | |||
| 874351aed7 | |||
| 1a89822bad | |||
| ac086fcd47 | |||
| 4b38a6fee1 | |||
| 60e838cba7 | |||
| 7b95ec579c | |||
| ab8fbc4b6e | |||
| f2130ad734 | |||
| c86e9c97b8 | |||
| 7639b0787e | |||
| 5e362d4af8 | |||
| 3b63d1b7e5 | |||
| 4a1e0f6896 | |||
| a83809eef7 | |||
| d993cd30a7 | |||
| 6bc59c8389 | |||
| b1d98c2b62 | |||
| 46ac50c393 | |||
| b834a8bc01 | |||
| 3c8ef3db91 | |||
| bc642d8f80 | |||
| a994bc4526 | |||
| 1243da3d22 | |||
| 6b33075a21 | |||
| c27ee326f7 | |||
| d27562a889 | |||
| e8d287de84 | |||
| c65c85a0c5 | |||
| bd81f2ffca | |||
| 5bd2a9cbea | |||
| e006521dc9 | |||
| 1ad07b1f51 | |||
| 9b68792ea9 | |||
| 8c8d412ef1 | |||
| 029f255351 | |||
| 19ac8cbd28 | |||
| f91e85651f | |||
| fbe6ebaa6b | |||
| 268ede8a9c | |||
| a63bae0bb9 | |||
| d454a094a0 | |||
| 1650b0e5a4 | |||
| 2d6f16692c | |||
| 017e25fb06 |
+1
-1
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
|
|
||||||
namespace Common.Configuration.DownloadCleaner;
|
namespace Common.Configuration.DownloadCleaner;
|
||||||
|
|
||||||
public sealed record Category : IConfig
|
public sealed record CleanCategory : IConfig
|
||||||
{
|
{
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
|||||||
public const string SectionName = "DownloadCleaner";
|
public const string SectionName = "DownloadCleaner";
|
||||||
|
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
public List<Category>? Categories { get; init; }
|
public List<CleanCategory>? Categories { get; init; }
|
||||||
|
|
||||||
[ConfigurationKeyName("DELETE_PRIVATE")]
|
[ConfigurationKeyName("DELETE_PRIVATE")]
|
||||||
public bool DeletePrivate { get; init; }
|
public bool DeletePrivate { get; init; }
|
||||||
@@ -17,6 +17,15 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
|||||||
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
|
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
|
||||||
public string? IgnoredDownloadsPath { get; init; }
|
public string? IgnoredDownloadsPath { get; init; }
|
||||||
|
|
||||||
|
[ConfigurationKeyName("UNLINKED_TARGET_CATEGORY")]
|
||||||
|
public string UnlinkedTargetCategory { get; init; } = "cleanuperr-unlinked";
|
||||||
|
|
||||||
|
[ConfigurationKeyName("UNLINKED_IGNORED_ROOT_DIR")]
|
||||||
|
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[ConfigurationKeyName("UNLINKED_CATEGORIES")]
|
||||||
|
public List<string>? UnlinkedCategories { get; init; }
|
||||||
|
|
||||||
public void Validate()
|
public void Validate()
|
||||||
{
|
{
|
||||||
if (!Enabled)
|
if (!Enabled)
|
||||||
@@ -31,9 +40,34 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
|||||||
|
|
||||||
if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true)
|
if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true)
|
||||||
{
|
{
|
||||||
throw new ValidationException("duplicated categories found");
|
throw new ValidationException("duplicated clean categories found");
|
||||||
}
|
}
|
||||||
|
|
||||||
Categories?.ForEach(x => x.Validate());
|
Categories?.ForEach(x => x.Validate());
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(UnlinkedTargetCategory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UnlinkedCategories?.Count is null or 0)
|
||||||
|
{
|
||||||
|
throw new ValidationException("no unlinked categories configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
|
||||||
|
{
|
||||||
|
throw new ValidationException($"{SectionName.ToUpperInvariant()}__UNLINKED_TARGET_CATEGORY should not be present in {SectionName.ToUpperInvariant()}__UNLINKED_CATEGORIES");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
|
||||||
|
{
|
||||||
|
throw new ValidationException("empty unlinked category filter found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
|
||||||
|
{
|
||||||
|
throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,16 @@ public abstract record NotificationConfig
|
|||||||
|
|
||||||
[ConfigurationKeyName("ON_DOWNLOAD_CLEANED")]
|
[ConfigurationKeyName("ON_DOWNLOAD_CLEANED")]
|
||||||
public bool OnDownloadCleaned { get; init; }
|
public bool OnDownloadCleaned { get; init; }
|
||||||
|
|
||||||
|
[ConfigurationKeyName("ON_CATEGORY_CHANGED")]
|
||||||
|
public bool OnCategoryChanged { get; init; }
|
||||||
|
|
||||||
public bool IsEnabled => OnImportFailedStrike || OnStalledStrike || OnQueueItemDeleted || OnDownloadCleaned;
|
public bool IsEnabled =>
|
||||||
|
OnImportFailedStrike ||
|
||||||
|
OnStalledStrike ||
|
||||||
|
OnQueueItemDeleted ||
|
||||||
|
OnDownloadCleaned ||
|
||||||
|
OnCategoryChanged;
|
||||||
|
|
||||||
public abstract bool IsValid();
|
public abstract bool IsValid();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Common.Exceptions;
|
||||||
|
|
||||||
|
public class FatalException : Exception
|
||||||
|
{
|
||||||
|
public FatalException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public FatalException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ public sealed record TorrentStatus
|
|||||||
[JsonProperty("total_done")]
|
[JsonProperty("total_done")]
|
||||||
public long TotalDone { get; init; }
|
public long TotalDone { get; init; }
|
||||||
|
|
||||||
public string? Label { get; init; }
|
public string? Label { get; set; }
|
||||||
|
|
||||||
[JsonProperty("seeding_time")]
|
[JsonProperty("seeding_time")]
|
||||||
public long SeedingTime { get; init; }
|
public long SeedingTime { get; init; }
|
||||||
@@ -25,6 +25,9 @@ public sealed record TorrentStatus
|
|||||||
public float Ratio { get; init; }
|
public float Ratio { get; init; }
|
||||||
|
|
||||||
public required IReadOnlyList<Tracker> Trackers { get; init; }
|
public required IReadOnlyList<Tracker> Trackers { get; init; }
|
||||||
|
|
||||||
|
[JsonProperty("download_location")]
|
||||||
|
public required string DownloadLocation { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record Tracker
|
public sealed record Tracker
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ public static class MainDI
|
|||||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||||
.AddHttpClients(configuration)
|
.AddHttpClients(configuration)
|
||||||
.AddConfiguration(configuration)
|
.AddConfiguration(configuration)
|
||||||
.AddMemoryCache()
|
.AddMemoryCache(options => {
|
||||||
|
options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
|
||||||
|
})
|
||||||
.AddServices()
|
.AddServices()
|
||||||
.AddQuartzServices(configuration)
|
.AddQuartzServices(configuration)
|
||||||
.AddNotifications(configuration)
|
.AddNotifications(configuration)
|
||||||
@@ -27,6 +29,7 @@ public static class MainDI
|
|||||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||||
|
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||||
|
|
||||||
config.UsingInMemory((context, cfg) =>
|
config.UsingInMemory((context, cfg) =>
|
||||||
{
|
{
|
||||||
@@ -36,6 +39,7 @@ public static class MainDI
|
|||||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||||
|
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||||
e.ConcurrentMessageLimit = 1;
|
e.ConcurrentMessageLimit = 1;
|
||||||
e.PrefetchCount = 1;
|
e.PrefetchCount = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Infrastructure.Verticals.DownloadClient;
|
|||||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.QueueCleaner;
|
using Infrastructure.Verticals.QueueCleaner;
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ public static class ServicesDI
|
|||||||
.AddTransient<ContentBlocker>()
|
.AddTransient<ContentBlocker>()
|
||||||
.AddTransient<DownloadCleaner>()
|
.AddTransient<DownloadCleaner>()
|
||||||
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
||||||
|
.AddTransient<IHardLinkFileService, HardLinkFileService>()
|
||||||
|
.AddTransient<UnixHardLinkFileService>()
|
||||||
|
.AddTransient<WindowsHardLinkFileService>()
|
||||||
.AddTransient<DummyDownloadService>()
|
.AddTransient<DummyDownloadService>()
|
||||||
.AddTransient<QBitService>()
|
.AddTransient<QBitService>()
|
||||||
.AddTransient<DelugeService>()
|
.AddTransient<DelugeService>()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"HTTP_MAX_RETRIES": 0,
|
"HTTP_MAX_RETRIES": 0,
|
||||||
"HTTP_TIMEOUT": 10,
|
"HTTP_TIMEOUT": 10,
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": "Debug",
|
"LogLevel": "Verbose",
|
||||||
"Enhanced": true,
|
"Enhanced": true,
|
||||||
"File": {
|
"File": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"RunSequentially": true,
|
"RunSequentially": true,
|
||||||
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads",
|
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads",
|
||||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
"IMPORT_FAILED_MAX_STRIKES": 3,
|
||||||
"IMPORT_FAILED_IGNORE_PRIVATE": true,
|
"IMPORT_FAILED_IGNORE_PRIVATE": true,
|
||||||
"IMPORT_FAILED_DELETE_PRIVATE": false,
|
"IMPORT_FAILED_DELETE_PRIVATE": false,
|
||||||
"IMPORT_FAILED_IGNORE_PATTERNS": [
|
"IMPORT_FAILED_IGNORE_PATTERNS": [
|
||||||
"file is a sample"
|
"file is a sample"
|
||||||
],
|
],
|
||||||
"STALLED_MAX_STRIKES": 5,
|
"STALLED_MAX_STRIKES": 3,
|
||||||
"STALLED_RESET_STRIKES_ON_PROGRESS": true,
|
"STALLED_RESET_STRIKES_ON_PROGRESS": true,
|
||||||
"STALLED_IGNORE_PRIVATE": true,
|
"STALLED_IGNORE_PRIVATE": true,
|
||||||
"STALLED_DELETE_PRIVATE": false
|
"STALLED_DELETE_PRIVATE": false
|
||||||
@@ -44,9 +44,15 @@
|
|||||||
"Name": "tv-sonarr",
|
"Name": "tv-sonarr",
|
||||||
"MAX_RATIO": -1,
|
"MAX_RATIO": -1,
|
||||||
"MIN_SEED_TIME": 0,
|
"MIN_SEED_TIME": 0,
|
||||||
"MAX_SEED_TIME": -1
|
"MAX_SEED_TIME": 240
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
|
||||||
|
"UNLINKED_IGNORED_ROOT_DIR": "",
|
||||||
|
"UNLINKED_CATEGORIES": [
|
||||||
|
"tv-sonarr",
|
||||||
|
"radarr"
|
||||||
|
],
|
||||||
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
|
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
|
||||||
},
|
},
|
||||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"DELETE_PRIVATE": false,
|
"DELETE_PRIVATE": false,
|
||||||
"CATEGORIES": [],
|
"CATEGORIES": [],
|
||||||
|
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
|
||||||
|
"UNLINKED_IGNORED_ROOT_DIR": "",
|
||||||
|
"UNLINKED_CATEGORIES": [],
|
||||||
"IGNORED_DOWNLOADS_PATH": ""
|
"IGNORED_DOWNLOADS_PATH": ""
|
||||||
},
|
},
|
||||||
"DOWNLOAD_CLIENT": "none",
|
"DOWNLOAD_CLIENT": "none",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Common.Configuration.ContentBlocker;
|
using Common.Configuration.ContentBlocker;
|
||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
using Common.Configuration.QueueCleaner;
|
using Common.Configuration.QueueCleaner;
|
||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.DownloadClient;
|
using Infrastructure.Verticals.DownloadClient;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -56,6 +57,7 @@ public class DownloadServiceFixture : IDisposable
|
|||||||
var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||||
var notifier = Substitute.For<INotificationPublisher>();
|
var notifier = Substitute.For<INotificationPublisher>();
|
||||||
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||||
|
var hardlinkFileService = Substitute.For<IHardLinkFileService>();
|
||||||
|
|
||||||
return new TestDownloadService(
|
return new TestDownloadService(
|
||||||
Logger,
|
Logger,
|
||||||
@@ -66,7 +68,8 @@ public class DownloadServiceFixture : IDisposable
|
|||||||
filenameEvaluator,
|
filenameEvaluator,
|
||||||
Striker,
|
Striker,
|
||||||
notifier,
|
notifier,
|
||||||
dryRunInterceptor
|
dryRunInterceptor,
|
||||||
|
hardlinkFileService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
|||||||
public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
|
public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
Category category = new()
|
CleanCategory category = new()
|
||||||
{
|
{
|
||||||
Name = "test",
|
Name = "test",
|
||||||
MaxRatio = 1.0,
|
MaxRatio = 1.0,
|
||||||
@@ -159,7 +159,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
|||||||
public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
|
public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
Category category = new()
|
CleanCategory category = new()
|
||||||
{
|
{
|
||||||
Name = "test",
|
Name = "test",
|
||||||
MaxRatio = 1.0,
|
MaxRatio = 1.0,
|
||||||
@@ -185,7 +185,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
|||||||
public void WhenMaxSeedTimeReached_ShouldReturnTrue()
|
public void WhenMaxSeedTimeReached_ShouldReturnTrue()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
Category category = new()
|
CleanCategory category = new()
|
||||||
{
|
{
|
||||||
Name = "test",
|
Name = "test",
|
||||||
MaxRatio = -1,
|
MaxRatio = -1,
|
||||||
@@ -211,7 +211,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
|||||||
public void WhenNeitherConditionMet_ShouldReturnFalse()
|
public void WhenNeitherConditionMet_ShouldReturnFalse()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
Category category = new()
|
CleanCategory category = new()
|
||||||
{
|
{
|
||||||
Name = "test",
|
Name = "test",
|
||||||
MaxRatio = 2.0,
|
MaxRatio = 2.0,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Common.Configuration.ContentBlocker;
|
using Common.Configuration.ContentBlocker;
|
||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
@@ -7,6 +7,7 @@ using Domain.Enums;
|
|||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.DownloadClient;
|
using Infrastructure.Verticals.DownloadClient;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -26,10 +27,11 @@ public class TestDownloadService : DownloadService
|
|||||||
IFilenameEvaluator filenameEvaluator,
|
IFilenameEvaluator filenameEvaluator,
|
||||||
IStriker striker,
|
IStriker striker,
|
||||||
INotificationPublisher notifier,
|
INotificationPublisher notifier,
|
||||||
IDryRunInterceptor dryRunInterceptor
|
IDryRunInterceptor dryRunInterceptor,
|
||||||
|
IHardLinkFileService hardLinkFileService
|
||||||
) : base(
|
) : base(
|
||||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -40,12 +42,14 @@ public class TestDownloadService : DownloadService
|
|||||||
public override Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType,
|
public override Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType,
|
||||||
ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads) => Task.FromResult(new BlockFilesResult());
|
ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads) => Task.FromResult(new BlockFilesResult());
|
||||||
public override Task DeleteDownload(string hash) => Task.CompletedTask;
|
public override Task DeleteDownload(string hash) => Task.CompletedTask;
|
||||||
public override Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories) => Task.FromResult<List<object>?>(null);
|
public override Task CreateCategoryAsync(string name) => Task.CompletedTask;
|
||||||
public override Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
public override Task<List<object>?> GetSeedingDownloads() => Task.FromResult<List<object>?>(null);
|
||||||
IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
|
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) => null;
|
||||||
|
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) => null;
|
||||||
|
public override Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
|
||||||
|
public override Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
|
||||||
// Expose protected methods for testing
|
// Expose protected methods for testing
|
||||||
public new void ResetStrikesOnProgress(string hash, long downloaded) => base.ResetStrikesOnProgress(hash, downloaded);
|
public new void ResetStrikesOnProgress(string hash, long downloaded) => base.ResetStrikesOnProgress(hash, downloaded);
|
||||||
public new Task<bool> StrikeAndCheckLimit(string hash, string itemName, StrikeType strikeType) => base.StrikeAndCheckLimit(hash, itemName, strikeType);
|
public new Task<bool> StrikeAndCheckLimit(string hash, string itemName, StrikeType strikeType) => base.StrikeAndCheckLimit(hash, itemName, strikeType);
|
||||||
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category) => base.ShouldCleanDownload(ratio, seedingTime, category);
|
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category) => base.ShouldCleanDownload(ratio, seedingTime, category);
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<PackageReference Include="MassTransit" Version="8.3.6" />
|
<PackageReference Include="MassTransit" Version="8.3.6" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||||
|
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
|
||||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||||
<PackageReference Include="Scrutor" Version="6.0.1" />
|
<PackageReference Include="Scrutor" Version="6.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class BlocklistProvider
|
|||||||
{
|
{
|
||||||
if (_initialized)
|
if (_initialized)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("blocklists already loaded");
|
_logger.LogTrace("blocklists already loaded");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public sealed class DownloadCleaner : GenericHandler
|
|||||||
private readonly IgnoredDownloadsProvider<DownloadCleanerConfig> _ignoredDownloadsProvider;
|
private readonly IgnoredDownloadsProvider<DownloadCleanerConfig> _ignoredDownloadsProvider;
|
||||||
private readonly HashSet<string> _excludedHashes = [];
|
private readonly HashSet<string> _excludedHashes = [];
|
||||||
|
|
||||||
|
private static bool _hardLinkCategoryCreated;
|
||||||
|
|
||||||
public DownloadCleaner(
|
public DownloadCleaner(
|
||||||
ILogger<DownloadCleaner> logger,
|
ILogger<DownloadCleaner> logger,
|
||||||
IOptions<DownloadCleanerConfig> config,
|
IOptions<DownloadCleanerConfig> config,
|
||||||
@@ -65,13 +67,20 @@ public sealed class DownloadCleaner : GenericHandler
|
|||||||
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
|
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
|
||||||
|
|
||||||
await _downloadService.LoginAsync();
|
await _downloadService.LoginAsync();
|
||||||
|
List<object>? downloads = await _downloadService.GetSeedingDownloads();
|
||||||
List<object>? downloads = await _downloadService.GetAllDownloadsToBeCleaned(_config.Categories);
|
List<object>? downloadsToChangeCategory = null;
|
||||||
|
|
||||||
if (downloads?.Count is null or 0)
|
if (!string.IsNullOrEmpty(_config.UnlinkedTargetCategory) && _config.UnlinkedCategories?.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("no downloads found in the download client");
|
if (!_hardLinkCategoryCreated)
|
||||||
return;
|
{
|
||||||
|
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
|
||||||
|
|
||||||
|
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
|
||||||
|
_hardLinkCategoryCreated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadsToChangeCategory = _downloadService.FilterDownloadsToChangeCategoryAsync(downloads, _config.UnlinkedCategories);
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for the downloads to appear in the arr queue
|
// wait for the downloads to appear in the arr queue
|
||||||
@@ -81,7 +90,16 @@ public sealed class DownloadCleaner : GenericHandler
|
|||||||
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true);
|
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true);
|
||||||
await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true);
|
await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true);
|
||||||
|
|
||||||
await _downloadService.CleanDownloads(downloads, _config.Categories, _excludedHashes, ignoredDownloads);
|
_logger.LogTrace("looking for downloads to change category");
|
||||||
|
await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads);
|
||||||
|
|
||||||
|
List<object>? downloadsToClean = _downloadService.FilterDownloadsToBeCleanedAsync(downloads, _config.Categories);
|
||||||
|
|
||||||
|
// release unused objects
|
||||||
|
downloads = null;
|
||||||
|
|
||||||
|
_logger.LogTrace("looking for downloads to clean");
|
||||||
|
await _downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Common.Configuration;
|
using Common.Configuration;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.DownloadClient;
|
||||||
|
using Common.Exceptions;
|
||||||
using Domain.Models.Deluge.Exceptions;
|
using Domain.Models.Deluge.Exceptions;
|
||||||
using Domain.Models.Deluge.Request;
|
using Domain.Models.Deluge.Request;
|
||||||
using Domain.Models.Deluge.Response;
|
using Domain.Models.Deluge.Response;
|
||||||
@@ -27,7 +28,8 @@ public sealed class DelugeClient
|
|||||||
"label",
|
"label",
|
||||||
"seeding_time",
|
"seeding_time",
|
||||||
"ratio",
|
"ratio",
|
||||||
"trackers"
|
"trackers",
|
||||||
|
"download_location"
|
||||||
];
|
];
|
||||||
|
|
||||||
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
|
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
|
||||||
@@ -42,11 +44,42 @@ public sealed class DelugeClient
|
|||||||
return await SendRequest<bool>("auth.login", _config.Password);
|
return await SendRequest<bool>("auth.login", _config.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsConnected()
|
||||||
|
{
|
||||||
|
return await SendRequest<bool>("web.connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Connect()
|
||||||
|
{
|
||||||
|
string? firstHost = await GetHost();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(firstHost))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await SendRequest<List<string>?>("web.connect", firstHost);
|
||||||
|
|
||||||
|
return result?.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> Logout()
|
public async Task<bool> Logout()
|
||||||
{
|
{
|
||||||
return await SendRequest<bool>("auth.delete_session");
|
return await SendRequest<bool>("auth.delete_session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetHost()
|
||||||
|
{
|
||||||
|
var hosts = await SendRequest<List<List<string>?>?>("web.get_hosts");
|
||||||
|
|
||||||
|
if (hosts?.Count > 1)
|
||||||
|
{
|
||||||
|
throw new FatalException("multiple Deluge hosts found - please connect to only one host");
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts?.FirstOrDefault()?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<DelugeTorrent>> ListTorrents(Dictionary<string, string>? filters = null)
|
public async Task<List<DelugeTorrent>> ListTorrents(Dictionary<string, string>? filters = null)
|
||||||
{
|
{
|
||||||
filters ??= new Dictionary<string, string>();
|
filters ??= new Dictionary<string, string>();
|
||||||
@@ -147,7 +180,7 @@ public sealed class DelugeClient
|
|||||||
return responseJson;
|
return responseJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DelugeRequest CreateRequest(string method, params object[] parameters)
|
private static DelugeRequest CreateRequest(string method, params object[] parameters)
|
||||||
{
|
{
|
||||||
if (String.IsNullOrWhiteSpace(method))
|
if (String.IsNullOrWhiteSpace(method))
|
||||||
{
|
{
|
||||||
@@ -193,4 +226,19 @@ public sealed class DelugeClient
|
|||||||
|
|
||||||
return webResponse.Result;
|
return webResponse.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetLabels()
|
||||||
|
{
|
||||||
|
return await SendRequest<IReadOnlyList<string>>("label.get_labels");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateLabel(string label)
|
||||||
|
{
|
||||||
|
await SendRequest<DelugeResponse<object>>("label.add", label);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetTorrentLabel(string hash, string newLabel)
|
||||||
|
{
|
||||||
|
await SendRequest<DelugeResponse<object>>("label.set_torrent", hash, newLabel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,14 @@ using Common.Configuration.ContentBlocker;
|
|||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.DownloadClient;
|
||||||
using Common.Configuration.QueueCleaner;
|
using Common.Configuration.QueueCleaner;
|
||||||
|
using Common.Exceptions;
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
using Domain.Models.Deluge.Response;
|
using Domain.Models.Deluge.Response;
|
||||||
using Infrastructure.Extensions;
|
using Infrastructure.Extensions;
|
||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.Context;
|
using Infrastructure.Verticals.Context;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -34,10 +36,11 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
IFilenameEvaluator filenameEvaluator,
|
IFilenameEvaluator filenameEvaluator,
|
||||||
IStriker striker,
|
IStriker striker,
|
||||||
INotificationPublisher notifier,
|
INotificationPublisher notifier,
|
||||||
IDryRunInterceptor dryRunInterceptor
|
IDryRunInterceptor dryRunInterceptor,
|
||||||
|
IHardLinkFileService hardLinkFileService
|
||||||
) : base(
|
) : base(
|
||||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
config.Value.Validate();
|
config.Value.Validate();
|
||||||
@@ -47,6 +50,11 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
public override async Task LoginAsync()
|
public override async Task LoginAsync()
|
||||||
{
|
{
|
||||||
await _client.LoginAsync();
|
await _client.LoginAsync();
|
||||||
|
|
||||||
|
if (!await _client.IsConnected() && !await _client.Connect())
|
||||||
|
{
|
||||||
|
throw new FatalException("Deluge WebUI is not connected to the daemon");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -124,9 +132,6 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ceva = await _client.GetTorrentExtended(hash);
|
|
||||||
|
|
||||||
|
|
||||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||||
@@ -209,26 +214,51 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
|
public override async Task<List<object>?> GetSeedingDownloads()
|
||||||
{
|
{
|
||||||
return (await _client.GetStatusForAllTorrents())
|
return (await _client.GetStatusForAllTorrents())
|
||||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||||
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
|
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
|
||||||
.Cast<object>()
|
.Cast<object>()
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||||
|
downloads
|
||||||
|
?.Cast<TorrentStatus>()
|
||||||
|
.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<TorrentStatus>()
|
||||||
|
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||||
|
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
.Cast<object>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
|
||||||
IReadOnlyList<string> ignoredDownloads)
|
IReadOnlyList<string> ignoredDownloads)
|
||||||
{
|
{
|
||||||
|
if (downloads?.Count is null or 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (TorrentStatus download in downloads)
|
foreach (TorrentStatus download in downloads)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(download.Hash))
|
if (string.IsNullOrEmpty(download.Hash))
|
||||||
{
|
{
|
||||||
continue;
|
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))
|
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||||
{
|
{
|
||||||
@@ -236,19 +266,13 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Category? category = categoriesToClean
|
CleanCategory? category = categoriesToClean
|
||||||
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
|
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
if (category is null)
|
if (category is null)
|
||||||
{
|
{
|
||||||
continue;
|
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 (!_downloadCleanerConfig.DeletePrivate && download.Private)
|
if (!_downloadCleanerConfig.DeletePrivate && download.Private)
|
||||||
{
|
{
|
||||||
@@ -280,7 +304,107 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
await _notifier.NotifyDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
|
await _notifier.NotifyDownloadCleaned(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 (TorrentStatus download in downloads.Cast<TorrentStatus>())
|
||||||
|
{
|
||||||
|
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 _notifier.NotifyCategoryChanged(download.Label, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||||
|
|
||||||
|
download.Label = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
[DryRunSafeguard]
|
[DryRunSafeguard]
|
||||||
public override async Task DeleteDownload(string hash)
|
public override async Task DeleteDownload(string hash)
|
||||||
@@ -289,6 +413,12 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
|
|
||||||
await _client.DeleteTorrents([hash]);
|
await _client.DeleteTorrents([hash]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DryRunSafeguard]
|
||||||
|
protected async Task CreateLabel(string name)
|
||||||
|
{
|
||||||
|
await _client.CreateLabel(name);
|
||||||
|
}
|
||||||
|
|
||||||
[DryRunSafeguard]
|
[DryRunSafeguard]
|
||||||
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
|
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
|
||||||
@@ -296,6 +426,12 @@ public class DelugeService : DownloadService, IDelugeService
|
|||||||
await _client.ChangeFilesPriority(hash, 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)> IsItemStuckAndShouldRemove(TorrentStatus status)
|
private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentStatus status)
|
||||||
{
|
{
|
||||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Common.Configuration.ContentBlocker;
|
using Common.Configuration.ContentBlocker;
|
||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
@@ -10,6 +10,7 @@ using Infrastructure.Helpers;
|
|||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.Context;
|
using Infrastructure.Verticals.Context;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -30,6 +31,7 @@ public abstract class DownloadService : IDownloadService
|
|||||||
protected readonly MemoryCacheEntryOptions _cacheOptions;
|
protected readonly MemoryCacheEntryOptions _cacheOptions;
|
||||||
protected readonly INotificationPublisher _notifier;
|
protected readonly INotificationPublisher _notifier;
|
||||||
protected readonly IDryRunInterceptor _dryRunInterceptor;
|
protected readonly IDryRunInterceptor _dryRunInterceptor;
|
||||||
|
protected readonly IHardLinkFileService _hardLinkFileService;
|
||||||
|
|
||||||
protected DownloadService(
|
protected DownloadService(
|
||||||
ILogger<DownloadService> logger,
|
ILogger<DownloadService> logger,
|
||||||
@@ -40,7 +42,8 @@ public abstract class DownloadService : IDownloadService
|
|||||||
IFilenameEvaluator filenameEvaluator,
|
IFilenameEvaluator filenameEvaluator,
|
||||||
IStriker striker,
|
IStriker striker,
|
||||||
INotificationPublisher notifier,
|
INotificationPublisher notifier,
|
||||||
IDryRunInterceptor dryRunInterceptor
|
IDryRunInterceptor dryRunInterceptor,
|
||||||
|
IHardLinkFileService hardLinkFileService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -52,6 +55,7 @@ public abstract class DownloadService : IDownloadService
|
|||||||
_striker = striker;
|
_striker = striker;
|
||||||
_notifier = notifier;
|
_notifier = notifier;
|
||||||
_dryRunInterceptor = dryRunInterceptor;
|
_dryRunInterceptor = dryRunInterceptor;
|
||||||
|
_hardLinkFileService = hardLinkFileService;
|
||||||
_cacheOptions = new MemoryCacheEntryOptions()
|
_cacheOptions = new MemoryCacheEntryOptions()
|
||||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||||
}
|
}
|
||||||
@@ -72,12 +76,23 @@ public abstract class DownloadService : IDownloadService
|
|||||||
public abstract Task DeleteDownload(string hash);
|
public abstract Task DeleteDownload(string hash);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public abstract Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories);
|
public abstract Task<List<object>?> GetSeedingDownloads();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public abstract Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
public abstract List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories);
|
||||||
IReadOnlyList<string> ignoredDownloads);
|
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract Task CreateCategoryAsync(string name);
|
||||||
|
|
||||||
protected void ResetStrikesOnProgress(string hash, long downloaded)
|
protected void ResetStrikesOnProgress(string hash, long downloaded)
|
||||||
{
|
{
|
||||||
if (!_queueCleanerConfig.StalledResetStrikesOnProgress)
|
if (!_queueCleanerConfig.StalledResetStrikesOnProgress)
|
||||||
@@ -107,7 +122,7 @@ public abstract class DownloadService : IDownloadService
|
|||||||
return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, strikeType);
|
return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, strikeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category)
|
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category)
|
||||||
{
|
{
|
||||||
// check ratio
|
// check ratio
|
||||||
if (DownloadReachedRatio(ratio, seedingTime, category))
|
if (DownloadReachedRatio(ratio, seedingTime, category))
|
||||||
@@ -131,8 +146,28 @@ public abstract class DownloadService : IDownloadService
|
|||||||
|
|
||||||
return new();
|
return new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected string? GetRootWithFirstDirectory(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, Category category)
|
string? root = Path.GetPathRoot(path);
|
||||||
|
|
||||||
|
if (root is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string relativePath = path[root.Length..].TrimStart(Path.DirectorySeparatorChar);
|
||||||
|
string[] parts = relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
return parts.Length > 0 ? Path.Combine(root, parts[0]) : root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, CleanCategory category)
|
||||||
{
|
{
|
||||||
if (category.MaxRatio < 0)
|
if (category.MaxRatio < 0)
|
||||||
{
|
{
|
||||||
@@ -158,7 +193,7 @@ public abstract class DownloadService : IDownloadService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, Category category)
|
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, CleanCategory category)
|
||||||
{
|
{
|
||||||
if (category.MaxSeedTime < 0)
|
if (category.MaxSeedTime < 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Common.Configuration.ContentBlocker;
|
using Common.Configuration.ContentBlocker;
|
||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
using Common.Configuration.QueueCleaner;
|
using Common.Configuration.QueueCleaner;
|
||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -15,7 +16,21 @@ namespace Infrastructure.Verticals.DownloadClient;
|
|||||||
|
|
||||||
public class DummyDownloadService : DownloadService
|
public class DummyDownloadService : DownloadService
|
||||||
{
|
{
|
||||||
public DummyDownloadService(ILogger<DownloadService> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<DownloadCleanerConfig> downloadCleanerConfig, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier, dryRunInterceptor)
|
public DummyDownloadService(
|
||||||
|
ILogger<DownloadService> logger,
|
||||||
|
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||||
|
IOptions<ContentBlockerConfig> contentBlockerConfig,
|
||||||
|
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
|
||||||
|
IMemoryCache cache,
|
||||||
|
IFilenameEvaluator filenameEvaluator,
|
||||||
|
IStriker striker,
|
||||||
|
INotificationPublisher notifier,
|
||||||
|
IDryRunInterceptor dryRunInterceptor,
|
||||||
|
IHardLinkFileService hardLinkFileService
|
||||||
|
) : base(
|
||||||
|
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig,
|
||||||
|
cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||||
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,13 +54,32 @@ public class DummyDownloadService : DownloadService
|
|||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
|
public override Task<List<object>?> GetSeedingDownloads()
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories)
|
||||||
IReadOnlyList<string> ignoredDownloads)
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task CreateCategoryAsync(string name)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,24 +34,52 @@ public interface IDownloadService : IDisposable
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches all downloads.
|
/// Fetches all seeding downloads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A list of downloads that are seeding.</returns>
|
||||||
|
Task<List<object>?> GetSeedingDownloads();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters downloads that should be cleaned.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="downloads">The downloads to filter.</param>
|
||||||
/// <param name="categories">The categories by which to filter the downloads.</param>
|
/// <param name="categories">The categories by which to filter the downloads.</param>
|
||||||
/// <returns>A list of downloads for the provided categories.</returns>
|
/// <returns>A list of downloads for the provided categories.</returns>
|
||||||
Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories);
|
List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters downloads that should have their category changed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="downloads">The downloads to filter.</param>
|
||||||
|
/// <param name="categories">The categories by which to filter the downloads.</param>
|
||||||
|
/// <returns>A list of downloads for the provided categories.</returns>
|
||||||
|
List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans the downloads.
|
/// Cleans the downloads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="downloads"></param>
|
/// <param name="downloads">The downloads to clean.</param>
|
||||||
/// <param name="categoriesToClean">The categories that should be cleaned.</param>
|
/// <param name="categoriesToClean">The categories that should be cleaned.</param>
|
||||||
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
|
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
|
||||||
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
|
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
|
||||||
public abstract Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||||
IReadOnlyList<string> ignoredDownloads);
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the category for downloads that have no hardlinks.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="downloads">The downloads to change.</param>
|
||||||
|
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
|
||||||
|
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
|
||||||
|
Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a download item.
|
/// Deletes a download item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task DeleteDownload(string hash);
|
public Task DeleteDownload(string hash);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a category.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The category name.</param>
|
||||||
|
public Task CreateCategoryAsync(string name);
|
||||||
}
|
}
|
||||||
@@ -5,19 +5,20 @@ using Common.Configuration.ContentBlocker;
|
|||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.DownloadClient;
|
||||||
using Common.Configuration.QueueCleaner;
|
using Common.Configuration.QueueCleaner;
|
||||||
|
using Common.Exceptions;
|
||||||
using Common.Helpers;
|
using Common.Helpers;
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
using Infrastructure.Extensions;
|
using Infrastructure.Extensions;
|
||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.Context;
|
using Infrastructure.Verticals.Context;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using QBittorrent.Client;
|
using QBittorrent.Client;
|
||||||
using Category = Common.Configuration.DownloadCleaner.Category;
|
|
||||||
|
|
||||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||||
|
|
||||||
@@ -37,10 +38,11 @@ public class QBitService : DownloadService, IQBitService
|
|||||||
IFilenameEvaluator filenameEvaluator,
|
IFilenameEvaluator filenameEvaluator,
|
||||||
IStriker striker,
|
IStriker striker,
|
||||||
INotificationPublisher notifier,
|
INotificationPublisher notifier,
|
||||||
IDryRunInterceptor dryRunInterceptor
|
IDryRunInterceptor dryRunInterceptor,
|
||||||
|
IHardLinkFileService hardLinkFileService
|
||||||
) : base(
|
) : base(
|
||||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_config = config.Value;
|
_config = config.Value;
|
||||||
@@ -226,20 +228,42 @@ public class QBitService : DownloadService, IQBitService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories) =>
|
public override async Task<List<object>?> GetSeedingDownloads() =>
|
||||||
(await _client.GetTorrentListAsync(new()
|
(await _client.GetTorrentListAsync(new()
|
||||||
{
|
{
|
||||||
Filter = TorrentListFilter.Seeding
|
Filter = TorrentListFilter.Seeding
|
||||||
}))
|
}))
|
||||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
|
||||||
.Cast<object>()
|
.Cast<object>()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||||
IReadOnlyList<string> ignoredDownloads)
|
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)))
|
||||||
|
.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)
|
foreach (TorrentInfo download in downloads)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(download.Hash))
|
if (string.IsNullOrEmpty(download.Hash))
|
||||||
@@ -247,16 +271,22 @@ public class QBitService : DownloadService, IQBitService
|
|||||||
continue;
|
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);
|
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||||
|
|
||||||
if (ignoredDownloads.Count > 0 &&
|
if (ignoredDownloads.Count > 0 &&
|
||||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
|
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Category? category = categoriesToClean
|
CleanCategory? category = categoriesToClean
|
||||||
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
if (category is null)
|
if (category is null)
|
||||||
@@ -264,12 +294,6 @@ public class QBitService : DownloadService, IQBitService
|
|||||||
continue;
|
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 (!_downloadCleanerConfig.DeletePrivate)
|
if (!_downloadCleanerConfig.DeletePrivate)
|
||||||
{
|
{
|
||||||
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash);
|
||||||
@@ -315,12 +339,125 @@ public class QBitService : DownloadService, IQBitService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task CreateCategoryAsync(string name)
|
||||||
|
{
|
||||||
|
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 (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);
|
||||||
|
|
||||||
|
_logger.LogInformation("category changed for {name}", download.Name);
|
||||||
|
|
||||||
|
await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||||
|
|
||||||
|
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
[DryRunSafeguard]
|
[DryRunSafeguard]
|
||||||
public override async Task DeleteDownload(string hash)
|
public override async Task DeleteDownload(string hash)
|
||||||
{
|
{
|
||||||
await _client.DeleteAsync(hash, deleteDownloadedData: true);
|
await _client.DeleteAsync(hash, deleteDownloadedData: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DryRunSafeguard]
|
||||||
|
protected async Task CreateCategory(string name)
|
||||||
|
{
|
||||||
|
await _client.AddCategoryAsync(name);
|
||||||
|
}
|
||||||
|
|
||||||
[DryRunSafeguard]
|
[DryRunSafeguard]
|
||||||
protected virtual async Task SkipFile(string hash, int fileIndex)
|
protected virtual async Task SkipFile(string hash, int fileIndex)
|
||||||
@@ -328,6 +465,12 @@ public class QBitService : DownloadService, IQBitService
|
|||||||
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
|
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DryRunSafeguard]
|
||||||
|
protected virtual async Task ChangeCategory(string hash, string newCategory)
|
||||||
|
{
|
||||||
|
await _client.SetTorrentCategoryAsync([hash], newCategory);
|
||||||
|
}
|
||||||
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ using Common.Configuration.ContentBlocker;
|
|||||||
using Common.Configuration.DownloadCleaner;
|
using Common.Configuration.DownloadCleaner;
|
||||||
using Common.Configuration.DownloadClient;
|
using Common.Configuration.DownloadClient;
|
||||||
using Common.Configuration.QueueCleaner;
|
using Common.Configuration.QueueCleaner;
|
||||||
|
using Common.Exceptions;
|
||||||
using Common.Helpers;
|
using Common.Helpers;
|
||||||
using Domain.Enums;
|
using Domain.Enums;
|
||||||
using Infrastructure.Extensions;
|
using Infrastructure.Extensions;
|
||||||
using Infrastructure.Interceptors;
|
using Infrastructure.Interceptors;
|
||||||
using Infrastructure.Verticals.ContentBlocker;
|
using Infrastructure.Verticals.ContentBlocker;
|
||||||
using Infrastructure.Verticals.Context;
|
using Infrastructure.Verticals.Context;
|
||||||
|
using Infrastructure.Verticals.Files;
|
||||||
using Infrastructure.Verticals.ItemStriker;
|
using Infrastructure.Verticals.ItemStriker;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Infrastructure.Verticals.Notifications;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -55,10 +57,11 @@ public class TransmissionService : DownloadService, ITransmissionService
|
|||||||
IFilenameEvaluator filenameEvaluator,
|
IFilenameEvaluator filenameEvaluator,
|
||||||
IStriker striker,
|
IStriker striker,
|
||||||
INotificationPublisher notifier,
|
INotificationPublisher notifier,
|
||||||
IDryRunInterceptor dryRunInterceptor
|
IDryRunInterceptor dryRunInterceptor,
|
||||||
|
IHardLinkFileService hardLinkFileService
|
||||||
) : base(
|
) : base(
|
||||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_config = config.Value;
|
_config = config.Value;
|
||||||
@@ -208,14 +211,38 @@ public class TransmissionService : DownloadService, ITransmissionService
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
public override async Task<List<object>?> GetSeedingDownloads()
|
||||||
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
|
|
||||||
{
|
{
|
||||||
return (await _client.TorrentGetAsync(Fields))
|
string[] fields = [
|
||||||
|
TorrentFields.FILES,
|
||||||
|
TorrentFields.FILE_STATS,
|
||||||
|
TorrentFields.HASH_STRING,
|
||||||
|
TorrentFields.ID,
|
||||||
|
TorrentFields.ETA,
|
||||||
|
TorrentFields.NAME,
|
||||||
|
TorrentFields.STATUS,
|
||||||
|
TorrentFields.IS_PRIVATE,
|
||||||
|
TorrentFields.DOWNLOADED_EVER,
|
||||||
|
TorrentFields.DOWNLOAD_DIR,
|
||||||
|
TorrentFields.SECONDS_SEEDING,
|
||||||
|
TorrentFields.UPLOAD_RATIO
|
||||||
|
];
|
||||||
|
|
||||||
|
return (await _client.TorrentGetAsync(fields))
|
||||||
?.Torrents
|
?.Torrents
|
||||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||||
.Where(x => x.Status is 5 or 6)
|
.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
|
.Where(x => categories
|
||||||
.Any(cat =>
|
.Any(cat =>
|
||||||
{
|
{
|
||||||
@@ -232,10 +259,20 @@ public class TransmissionService : DownloadService, ITransmissionService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||||
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
|
||||||
IReadOnlyList<string> ignoredDownloads)
|
|
||||||
{
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
foreach (TorrentInfo download in downloads)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(download.HashString))
|
if (string.IsNullOrEmpty(download.HashString))
|
||||||
@@ -249,7 +286,7 @@ public class TransmissionService : DownloadService, ITransmissionService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Category? category = categoriesToClean
|
CleanCategory? category = categoriesToClean
|
||||||
.FirstOrDefault(x =>
|
.FirstOrDefault(x =>
|
||||||
{
|
{
|
||||||
if (download.DownloadDir is null)
|
if (download.DownloadDir is null)
|
||||||
@@ -303,6 +340,138 @@ public class TransmissionService : DownloadService, ITransmissionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task CreateCategoryAsync(string name)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||||
|
{
|
||||||
|
if (downloads?.Count is null or 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO ignored downloads
|
||||||
|
throw new NotImplementedException();
|
||||||
|
|
||||||
|
// if (_downloadCleanerConfig.NoHardLinksIgnoreRootDir)
|
||||||
|
// {
|
||||||
|
// downloads
|
||||||
|
// .Cast<TorrentInfo>()
|
||||||
|
// .Select(x =>
|
||||||
|
// {
|
||||||
|
// if (x.DownloadDir == null)
|
||||||
|
// {
|
||||||
|
// return string.Empty;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// string? firstDir = GetRootWithFirstDirectory(x.DownloadDir);
|
||||||
|
//
|
||||||
|
// if (string.IsNullOrEmpty(firstDir))
|
||||||
|
// {
|
||||||
|
// return string.Empty;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (firstDir == Path.GetPathRoot(x.DownloadDir))
|
||||||
|
// {
|
||||||
|
// return string.Empty;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return firstDir;
|
||||||
|
// })
|
||||||
|
// .Where(x => !string.IsNullOrEmpty(x))
|
||||||
|
// .Distinct()
|
||||||
|
// .ToList()
|
||||||
|
// .ForEach(x =>
|
||||||
|
// {
|
||||||
|
// _logger.LogTrace("populating file counts from {dir}", x);
|
||||||
|
//
|
||||||
|
// if (!Directory.Exists(x))
|
||||||
|
// {
|
||||||
|
// throw new ValidationException($"directory \"{x}\" does not exist");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// _hardLinkFileService.PopulateFileCounts(x);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// foreach (TorrentInfo download in downloads.Cast<TorrentInfo>())
|
||||||
|
// {
|
||||||
|
// if (string.IsNullOrEmpty(download.HashString) || download.DownloadDir == null)
|
||||||
|
// {
|
||||||
|
// _logger.LogDebug("skip | download hash or download directory is null for {name}", download.Name);
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
// {
|
||||||
|
// _logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ContextProvider.Set("downloadName", download.Name);
|
||||||
|
// ContextProvider.Set("hash", download.HashString);
|
||||||
|
//
|
||||||
|
// bool hasHardlinks = false;
|
||||||
|
//
|
||||||
|
// if (download.Files != null)
|
||||||
|
// {
|
||||||
|
// foreach (TransmissionTorrentFiles file in download.Files)
|
||||||
|
// {
|
||||||
|
// string filePath = Path.Combine(download.DownloadDir, file.Name);
|
||||||
|
//
|
||||||
|
// long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, _downloadCleanerConfig.NoHardLinksIgnoreRootDir);
|
||||||
|
//
|
||||||
|
// if (hardlinkCount < 0)
|
||||||
|
// {
|
||||||
|
// _logger.LogDebug("skip | could not get file properties | {name}", download.Name);
|
||||||
|
// hasHardlinks = true;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (hardlinkCount > 0)
|
||||||
|
// {
|
||||||
|
// hasHardlinks = true;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (hasHardlinks)
|
||||||
|
// {
|
||||||
|
// _logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Get the current category (directory name)
|
||||||
|
// string currentCategory = Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir));
|
||||||
|
//
|
||||||
|
// // Create the new location path
|
||||||
|
// string newLocation = Path.Combine(
|
||||||
|
// Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(download.DownloadDir)) ?? string.Empty,
|
||||||
|
// _downloadCleanerConfig.NoHardLinksCategory
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// await _dryRunInterceptor.InterceptAsync(MoveDownload, download.Id, newLocation);
|
||||||
|
//
|
||||||
|
// _logger.LogInformation("category changed for {name}", download.Name);
|
||||||
|
//
|
||||||
|
// await _notifier.NotifyCategoryChanged(currentCategory, _downloadCleanerConfig.NoHardLinksCategory);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DryRunSafeguard]
|
||||||
|
protected virtual async Task MoveDownload(long downloadId, string newLocation)
|
||||||
|
{
|
||||||
|
await _client.TorrentSetAsync(new TorrentSettings
|
||||||
|
{
|
||||||
|
Ids = [downloadId],
|
||||||
|
Location = newLocation,
|
||||||
|
// Move = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task DeleteDownload(string hash)
|
public override async Task DeleteDownload(string hash)
|
||||||
{
|
{
|
||||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Infrastructure.Verticals.Files;
|
||||||
|
|
||||||
|
public class HardLinkFileService : IHardLinkFileService
|
||||||
|
{
|
||||||
|
private readonly ILogger<HardLinkFileService> _logger;
|
||||||
|
private readonly UnixHardLinkFileService _unixHardLinkFileService;
|
||||||
|
private readonly WindowsHardLinkFileService _windowsHardLinkFileService;
|
||||||
|
|
||||||
|
public HardLinkFileService(
|
||||||
|
ILogger<HardLinkFileService> logger,
|
||||||
|
UnixHardLinkFileService unixHardLinkFileService,
|
||||||
|
WindowsHardLinkFileService windowsHardLinkFileService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_unixHardLinkFileService = unixHardLinkFileService;
|
||||||
|
_windowsHardLinkFileService = windowsHardLinkFileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PopulateFileCounts(string directoryPath)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("populating file counts from {dir}", directoryPath);
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
_windowsHardLinkFileService.PopulateFileCounts(directoryPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_unixHardLinkFileService.PopulateFileCounts(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("file {file} does not exist", filePath);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
return _windowsHardLinkFileService.GetHardLinkCount(filePath, ignoreRootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _unixHardLinkFileService.GetHardLinkCount(filePath, ignoreRootDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Infrastructure.Verticals.Files;
|
||||||
|
|
||||||
|
public interface IHardLinkFileService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Populates the inode counts for Unix and the file index counts for Windows.
|
||||||
|
/// Needs to be called before <see cref="GetHardLinkCount"/> to populate the inode counts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="directoryPath">The root directory where to search for hardlinks.</param>
|
||||||
|
void PopulateFileCounts(string directoryPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the hardlink count of a file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filePath">File path.</param>
|
||||||
|
/// <param name="ignoreRootDir">Whether to ignore hardlinks found in the same root dir.</param>
|
||||||
|
/// <returns>-1 on error, 0 if there are no hardlinks and 1 otherwise.</returns>
|
||||||
|
long GetHardLinkCount(string filePath, bool ignoreRootDir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Mono.Unix.Native;
|
||||||
|
|
||||||
|
namespace Infrastructure.Verticals.Files;
|
||||||
|
|
||||||
|
public class UnixHardLinkFileService : IHardLinkFileService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<UnixHardLinkFileService> _logger;
|
||||||
|
private readonly ConcurrentDictionary<ulong, int> _inodeCounts = new();
|
||||||
|
|
||||||
|
public UnixHardLinkFileService(ILogger<UnixHardLinkFileService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Syscall.stat(filePath, out Stat stat) != 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("failed to stat file {file}", filePath);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ignoreRootDir)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("stat file | hardlinks: {nlink} | {file}", stat.st_nlink, filePath);
|
||||||
|
return (long)stat.st_nlink == 1 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the number of hardlinks in the same root directory
|
||||||
|
int linksInIgnoredDir = _inodeCounts.TryGetValue(stat.st_ino, out int count)
|
||||||
|
? count
|
||||||
|
: 1; // default to 1 if not found
|
||||||
|
|
||||||
|
_logger.LogDebug("stat file | hardlinks: {nlink} | ignored: {ignored} | {file}", stat.st_nlink, linksInIgnoredDir, filePath);
|
||||||
|
return (long)stat.st_nlink - linksInIgnoredDir;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "failed to stat file {file}", filePath);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void PopulateFileCounts(string directoryPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// traverse all files in the ignored path and subdirectories
|
||||||
|
foreach (string file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
AddInodeToCount(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "failed to populate inode counts from {dir}", directoryPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInodeToCount(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Syscall.stat(path, out Stat stat) == 0)
|
||||||
|
{
|
||||||
|
_inodeCounts.AddOrUpdate(stat.st_ino, 1, (_, count) => count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "could not stat {path} during inode counting", path);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_inodeCounts.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace Infrastructure.Verticals.Files;
|
||||||
|
|
||||||
|
public class WindowsHardLinkFileService : IHardLinkFileService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<WindowsHardLinkFileService> _logger;
|
||||||
|
private readonly ConcurrentDictionary<ulong, int> _fileIndexCounts = new();
|
||||||
|
|
||||||
|
public WindowsHardLinkFileService(ILogger<WindowsHardLinkFileService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SafeFileHandle fileStream = File.OpenHandle(filePath);
|
||||||
|
|
||||||
|
if (!GetFileInformationByHandle(fileStream, out var file))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("failed to get file handle {file}", filePath);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ignoreRootDir)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("stat file | hardlinks: {nlink} | {file}", file.NumberOfLinks, filePath);
|
||||||
|
return file.NumberOfLinks == 1 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique file ID (combination of high and low indices)
|
||||||
|
ulong fileIndex = ((ulong)file.FileIndexHigh << 32) | file.FileIndexLow;
|
||||||
|
|
||||||
|
// get the number of hardlinks in the same root directory
|
||||||
|
int linksInIgnoredDir = _fileIndexCounts.TryGetValue(fileIndex, out int count)
|
||||||
|
? count
|
||||||
|
: 1; // default to 1 if not found
|
||||||
|
|
||||||
|
_logger.LogDebug("stat file | hardlinks: {links} | ignored: {ignored} | {file}", file.NumberOfLinks, linksInIgnoredDir, filePath);
|
||||||
|
return file.NumberOfLinks - linksInIgnoredDir;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "failed to stat file {file}", filePath);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void PopulateFileCounts(string directoryPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// traverse all files in the ignored path and subdirectories
|
||||||
|
foreach (string file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
AddFileIndexToCount(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to populate file index counts from {dir}", directoryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddFileIndexToCount(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SafeFileHandle fileStream = File.OpenHandle(path);
|
||||||
|
if (GetFileInformationByHandle(fileStream, out var file))
|
||||||
|
{
|
||||||
|
ulong fileIndex = ((ulong)file.FileIndexHigh << 32) | file.FileIndexLow;
|
||||||
|
_fileIndexCounts.AddOrUpdate(fileIndex, 1, (_, count) => count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Couldn't stat {path} during file index counting", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool GetFileInformationByHandle(
|
||||||
|
SafeFileHandle hFile,
|
||||||
|
out BY_HANDLE_FILE_INFORMATION lpFileInformation
|
||||||
|
);
|
||||||
|
|
||||||
|
private struct BY_HANDLE_FILE_INFORMATION
|
||||||
|
{
|
||||||
|
public uint FileAttributes;
|
||||||
|
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||||
|
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||||
|
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||||
|
public uint VolumeSerialNumber;
|
||||||
|
public uint FileSizeHigh;
|
||||||
|
public uint FileSizeLow;
|
||||||
|
public uint NumberOfLinks;
|
||||||
|
public uint FileIndexHigh;
|
||||||
|
public uint FileIndexLow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_fileIndexCounts.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notificatio
|
|||||||
case DownloadCleanedNotification downloadCleanedNotification:
|
case DownloadCleanedNotification downloadCleanedNotification:
|
||||||
await _notificationService.Notify(downloadCleanedNotification);
|
await _notificationService.Notify(downloadCleanedNotification);
|
||||||
break;
|
break;
|
||||||
|
case CategoryChangedNotification categoryChangedNotification:
|
||||||
|
await _notificationService.Notify(categoryChangedNotification);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public interface INotificationFactory
|
|||||||
List<INotificationProvider> OnQueueItemDeletedEnabled();
|
List<INotificationProvider> OnQueueItemDeletedEnabled();
|
||||||
|
|
||||||
List<INotificationProvider> OnDownloadCleanedEnabled();
|
List<INotificationProvider> OnDownloadCleanedEnabled();
|
||||||
|
|
||||||
|
List<INotificationProvider> OnCategoryChangedEnabled();
|
||||||
}
|
}
|
||||||
@@ -16,4 +16,6 @@ public interface INotificationProvider
|
|||||||
Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
|
Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
|
||||||
|
|
||||||
Task OnDownloadCleaned(DownloadCleanedNotification notification);
|
Task OnDownloadCleaned(DownloadCleanedNotification notification);
|
||||||
|
|
||||||
|
Task OnCategoryChanged(CategoryChangedNotification notification);
|
||||||
}
|
}
|
||||||
@@ -9,4 +9,6 @@ public interface INotificationPublisher
|
|||||||
Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason);
|
Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason);
|
||||||
|
|
||||||
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
|
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
|
||||||
|
|
||||||
|
Task NotifyCategoryChanged(string oldCategory, string newCategory);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Infrastructure.Verticals.Notifications.Models;
|
||||||
|
|
||||||
|
public sealed record CategoryChangedNotification : Notification
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ public class NotifiarrProvider : NotificationProvider
|
|||||||
{
|
{
|
||||||
await _proxy.SendNotification(BuildPayload(notification), _config);
|
await _proxy.SendNotification(BuildPayload(notification), _config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
|
||||||
|
{
|
||||||
|
await _proxy.SendNotification(BuildPayload(notification), _config);
|
||||||
|
}
|
||||||
|
|
||||||
private NotifiarrPayload BuildPayload(ArrNotification notification, string color)
|
private NotifiarrPayload BuildPayload(ArrNotification notification, string color)
|
||||||
{
|
{
|
||||||
@@ -105,4 +110,32 @@ public class NotifiarrProvider : NotificationProvider
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private NotifiarrPayload BuildPayload(CategoryChangedNotification notification)
|
||||||
|
{
|
||||||
|
NotifiarrPayload payload = new()
|
||||||
|
{
|
||||||
|
Discord = new()
|
||||||
|
{
|
||||||
|
Color = WarningColor,
|
||||||
|
Text = new()
|
||||||
|
{
|
||||||
|
Title = notification.Title,
|
||||||
|
Icon = Logo,
|
||||||
|
Description = notification.Description,
|
||||||
|
Fields = notification.Fields?.Adapt<List<Field>>() ?? []
|
||||||
|
},
|
||||||
|
Ids = new Ids
|
||||||
|
{
|
||||||
|
Channel = _config.ChannelId
|
||||||
|
},
|
||||||
|
Images = new()
|
||||||
|
{
|
||||||
|
Thumbnail = new Uri(Logo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Common.Helpers;
|
using Common.Helpers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
|
|
||||||
@@ -7,12 +8,14 @@ namespace Infrastructure.Verticals.Notifications.Notifiarr;
|
|||||||
|
|
||||||
public class NotifiarrProxy : INotifiarrProxy
|
public class NotifiarrProxy : INotifiarrProxy
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<NotifiarrProxy> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
private const string Url = "https://notifiarr.com/api/v1/notification/passthrough/";
|
private const string Url = "https://notifiarr.com/api/v1/notification/passthrough/";
|
||||||
|
|
||||||
public NotifiarrProxy(IHttpClientFactory httpClientFactory)
|
public NotifiarrProxy(ILogger<NotifiarrProxy> logger, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ public class NotifiarrProxy : INotifiarrProxy
|
|||||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_logger.LogTrace("sending notification to Notifiarr: {content}", content);
|
||||||
|
|
||||||
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{Url}{config.ApiKey}");
|
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{Url}{config.ApiKey}");
|
||||||
request.Method = HttpMethod.Post;
|
request.Method = HttpMethod.Post;
|
||||||
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ public class NotificationFactory : INotificationFactory
|
|||||||
ActiveProviders()
|
ActiveProviders()
|
||||||
.Where(n => n.Config.OnDownloadCleaned)
|
.Where(n => n.Config.OnDownloadCleaned)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
public List<INotificationProvider> OnCategoryChangedEnabled() =>
|
||||||
|
ActiveProviders()
|
||||||
|
.Where(n => n.Config.OnCategoryChanged)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -22,4 +22,6 @@ public abstract class NotificationProvider : INotificationProvider
|
|||||||
public abstract Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
|
public abstract Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
|
||||||
|
|
||||||
public abstract Task OnDownloadCleaned(DownloadCleanedNotification notification);
|
public abstract Task OnDownloadCleaned(DownloadCleanedNotification notification);
|
||||||
|
|
||||||
|
public abstract Task OnCategoryChanged(CategoryChangedNotification notification);
|
||||||
}
|
}
|
||||||
@@ -48,10 +48,10 @@ public class NotificationPublisher : INotificationPublisher
|
|||||||
switch (strikeType)
|
switch (strikeType)
|
||||||
{
|
{
|
||||||
case StrikeType.Stalled:
|
case StrikeType.Stalled:
|
||||||
await _dryRunInterceptor.InterceptAsync(Notify<StalledStrikeNotification>, notification.Adapt<StalledStrikeNotification>());
|
await NotifyInternal(notification.Adapt<StalledStrikeNotification>());
|
||||||
break;
|
break;
|
||||||
case StrikeType.ImportFailed:
|
case StrikeType.ImportFailed:
|
||||||
await _dryRunInterceptor.InterceptAsync(Notify<FailedImportStrikeNotification>, notification.Adapt<FailedImportStrikeNotification>());
|
await NotifyInternal(notification.Adapt<FailedImportStrikeNotification>());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ public class NotificationPublisher : INotificationPublisher
|
|||||||
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
|
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
|
||||||
};
|
};
|
||||||
|
|
||||||
await _dryRunInterceptor.InterceptAsync(Notify<QueueItemDeletedNotification>, notification);
|
await NotifyInternal(notification);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -110,13 +110,36 @@ public class NotificationPublisher : INotificationPublisher
|
|||||||
Level = NotificationLevel.Important
|
Level = NotificationLevel.Important
|
||||||
};
|
};
|
||||||
|
|
||||||
await _dryRunInterceptor.InterceptAsync(Notify<DownloadCleanedNotification>, notification);
|
await NotifyInternal(notification);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "failed to notify download cleaned");
|
_logger.LogError(ex, "failed to notify download cleaned");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory)
|
||||||
|
{
|
||||||
|
CategoryChangedNotification notification = new()
|
||||||
|
{
|
||||||
|
Title = "Category changed",
|
||||||
|
Description = ContextProvider.Get<string>("downloadName"),
|
||||||
|
Fields =
|
||||||
|
[
|
||||||
|
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
|
||||||
|
new() { Title = "Old category", Text = oldCategory },
|
||||||
|
new() { Title = "New category", Text = newCategory }
|
||||||
|
],
|
||||||
|
Level = NotificationLevel.Important
|
||||||
|
};
|
||||||
|
|
||||||
|
await NotifyInternal(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task NotifyInternal<T>(T message) where T: notnull
|
||||||
|
{
|
||||||
|
return _dryRunInterceptor.InterceptAsync(Notify<T>, message);
|
||||||
|
}
|
||||||
|
|
||||||
[DryRunSafeguard]
|
[DryRunSafeguard]
|
||||||
private Task Notify<T>(T message) where T: notnull
|
private Task Notify<T>(T message) where T: notnull
|
||||||
|
|||||||
@@ -73,4 +73,19 @@ public class NotificationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task Notify(CategoryChangedNotification notification)
|
||||||
|
{
|
||||||
|
foreach (INotificationProvider provider in _notificationFactory.OnCategoryChangedEnabled())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await provider.OnCategoryChanged(notification);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ services:
|
|||||||
- TZ=Europe/Bucharest
|
- TZ=Europe/Bucharest
|
||||||
- DRY_RUN=false
|
- DRY_RUN=false
|
||||||
|
|
||||||
- LOGGING__LOGLEVEL=Debug
|
- LOGGING__LOGLEVEL=Verbose
|
||||||
- LOGGING__FILE__ENABLED=true
|
- LOGGING__FILE__ENABLED=true
|
||||||
- LOGGING__FILE__PATH=/var/logs
|
- LOGGING__FILE__PATH=/var/logs
|
||||||
- LOGGING__ENHANCED=true
|
- LOGGING__ENHANCED=true
|
||||||
@@ -212,11 +212,15 @@ services:
|
|||||||
- DOWNLOADCLEANER__CATEGORIES__0__NAME=tv-sonarr
|
- DOWNLOADCLEANER__CATEGORIES__0__NAME=tv-sonarr
|
||||||
- DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO=-1
|
- DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO=-1
|
||||||
- DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME=0
|
- DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME=0
|
||||||
- DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=0.01
|
- DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=99999
|
||||||
- DOWNLOADCLEANER__CATEGORIES__1__NAME=radarr
|
- DOWNLOADCLEANER__CATEGORIES__1__NAME=nohardlink
|
||||||
- DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1
|
- DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1
|
||||||
- DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0
|
- DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0
|
||||||
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=0.01
|
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=99999
|
||||||
|
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
|
||||||
|
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
|
||||||
|
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
|
||||||
|
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
|
||||||
|
|
||||||
- DOWNLOAD_CLIENT=qbittorrent
|
- DOWNLOAD_CLIENT=qbittorrent
|
||||||
- QBITTORRENT__URL=http://qbittorrent:8080
|
- QBITTORRENT__URL=http://qbittorrent:8080
|
||||||
@@ -255,11 +259,13 @@ services:
|
|||||||
# - NOTIFIARR__ON_STALLED_STRIKE=true
|
# - NOTIFIARR__ON_STALLED_STRIKE=true
|
||||||
# - NOTIFIARR__ON_QUEUE_ITEM_DELETED=true
|
# - NOTIFIARR__ON_QUEUE_ITEM_DELETED=true
|
||||||
# - NOTIFIARR__ON_DOWNLOAD_CLEANED=true
|
# - NOTIFIARR__ON_DOWNLOAD_CLEANED=true
|
||||||
|
# - NOTIFIARR__ON_CATEGORY_CHANGED=true
|
||||||
# - NOTIFIARR__API_KEY=notifiarr_secret
|
# - NOTIFIARR__API_KEY=notifiarr_secret
|
||||||
# - NOTIFIARR__CHANNEL_ID=discord_channel_id
|
# - NOTIFIARR__CHANNEL_ID=discord_channel_id
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/cleanuperr/logs:/var/logs
|
- ./data/cleanuperr/logs:/var/logs
|
||||||
- ./data/cleanuperr/ignored_downloads:/ignored
|
- ./data/cleanuperr/ignored_downloads:/ignored
|
||||||
|
- ./data/qbittorrent/downloads:/downloads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- qbittorrent
|
- qbittorrent
|
||||||
|
|||||||
Reference in New Issue
Block a user