Compare commits

..

3 Commits

Author SHA1 Message Date
Marius Nechifor f0dc51f10b Improve stalled and failed imports (#37) 2025-01-13 13:18:58 +02:00
Flaminel c7ad1c5ee6 fixed README typo 2025-01-11 01:45:45 +02:00
Marius Nechifor d7913ae2b8 Add option to not use a download client (#35) 2025-01-11 01:45:12 +02:00
28 changed files with 287 additions and 61 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
name: Bug report name: Bug report
description: File a bug report if something is not working right. description: File a bug report if something is not working right.
title: "[BUG]: " title: "[BUG] "
labels: ["bug"] labels: ["bug"]
body: body:
- type: markdown - type: markdown
@@ -40,6 +40,7 @@ body:
- Windows - Windows
- Linux - Linux
- MacOS - MacOS
- Unraid
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
+1 -1
View File
@@ -1,6 +1,6 @@
name: Feature request name: Feature request
description: File a feature request. description: File a feature request.
title: "[FEATURE]: " title: "[FEATURE] "
labels: ["enhancement"] labels: ["enhancement"]
body: body:
- type: markdown - type: markdown
+1 -1
View File
@@ -1,6 +1,6 @@
name: Help request name: Help request
description: Ask a question to receive help. description: Ask a question to receive help.
title: "[HELP]: " title: "[HELP] "
labels: ["question"] labels: ["question"]
body: body:
- type: markdown - type: markdown
+24 -2
View File
@@ -63,12 +63,21 @@ This tool is actively developed and still a work in progress. Join the Discord s
## Using cleanuperr's blocklist (works with all supported download clients) ## Using cleanuperr's blocklist (works with all supported download clients)
1. Set both `QUEUECLEANER_ENABLED` and `CONTENTBLOCKER_ENABLED` to `true` in your environment variables. 1. Set both `QUEUECLEANER__ENABLED` and `CONTENTBLOCKER_ENABLED` to `true` in your environment variables.
2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Environment variables](#Environment-variables) section. 2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Environment variables](#Environment-variables) section.
3. Once configured, cleanuperr will perform the following tasks: 3. Once configured, cleanuperr will perform the following tasks:
- Execute the **content blocker** job, as explained in the [How it works](#how-it-works) section. - Execute the **content blocker** job, as explained in the [How it works](#how-it-works) section.
- Execute the **queue cleaner** job, as explained in the [How it works](#how-it-works) section. - Execute the **queue cleaner** job, as explained in the [How it works](#how-it-works) section.
## Using cleanuperr just for failed *arr imports (works for Usenet users as well)
1. Set `QUEUECLEANER__ENABLED` to `true`.
2. Set `QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES` to a desired value.
3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`.
4. Set `DOWNLOAD_CLIENT` to `none`.
**No other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).**
## Usage ## Usage
### Docker compose yaml ### Docker compose yaml
@@ -91,7 +100,11 @@ services:
- QUEUECLEANER__ENABLED=true - QUEUECLEANER__ENABLED=true
- QUEUECLEANER__RUNSEQUENTIALLY=true - QUEUECLEANER__RUNSEQUENTIALLY=true
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5 - QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=false
# - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=title mismatch
# - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1=manual import required
- QUEUECLEANER__STALLED_MAX_STRIKES=5 - QUEUECLEANER__STALLED_MAX_STRIKES=5
- QUEUECLEANER__STALLED_IGNORE_PRIVATE=false
- CONTENTBLOCKER__ENABLED=true - CONTENTBLOCKER__ENABLED=true
- CONTENTBLOCKER__BLACKLIST__ENABLED=true - CONTENTBLOCKER__BLACKLIST__ENABLED=true
@@ -113,6 +126,8 @@ services:
# - TRANSMISSION__URL=http://localhost:9091 # - TRANSMISSION__URL=http://localhost:9091
# - TRANSMISSION__USERNAME=test # - TRANSMISSION__USERNAME=test
# - TRANSMISSION__PASSWORD=testing # - TRANSMISSION__PASSWORD=testing
# OR
# - DOWNLOAD_CLIENT=none
- SONARR__ENABLED=true - SONARR__ENABLED=true
- SONARR__SEARCHTYPE=Episode - SONARR__SEARCHTYPE=Episode
@@ -145,7 +160,10 @@ services:
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner | true | | QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner | true |
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process | true | | QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process | true |
| QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | After how many strikes should a failed import be removed<br>0 means never | 0 | | QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | After how many strikes should a failed import be removed<br>0 means never | 0 |
| QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE | No | Whether to ignore failed imports from private trackers | false |
| QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0 | No | First pattern to look for when an import is failed<br>If the specified message pattern is found, the item is skipped | empty |
| QUEUECLEANER__STALLED_MAX_STRIKES | No | After how many strikes should a stalled download be removed<br>0 means never | 0 | | QUEUECLEANER__STALLED_MAX_STRIKES | No | After how many strikes should a stalled download be removed<br>0 means never | 0 |
| QUEUECLEANER__STALLED_IGNORE_PRIVATE | No | Whether to ignore stalled downloads from private trackers | false |
||||| |||||
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker | false | | CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker | false |
| CONTENTBLOCKER__BLACKLIST__ENABLED | Yes if content blocker is enabled and whitelist is not enabled | Enable or disable the blacklist | false | | CONTENTBLOCKER__BLACKLIST__ENABLED | Yes if content blocker is enabled and whitelist is not enabled | Enable or disable the blacklist | false |
@@ -153,7 +171,7 @@ services:
| CONTENTBLOCKER__WHITELIST__ENABLED | Yes if content blocker is enabled and blacklist is not enabled | Enable or disable the whitelist | false | | CONTENTBLOCKER__WHITELIST__ENABLED | Yes if content blocker is enabled and blacklist is not enabled | Enable or disable the whitelist | false |
| CONTENTBLOCKER__WHITELIST__PATH | Yes if whitelist is enabled | Path to the whitelist (local file or url)<br>Needs to be json compatible | empty | | CONTENTBLOCKER__WHITELIST__PATH | Yes if whitelist is enabled | Path to the whitelist (local file or url)<br>Needs to be json compatible | empty |
||||| |||||
| DOWNLOAD_CLIENT | No | Download client that is used by *arrs<br>Can be `qbittorrent`, `deluge` or `transmission` | `qbittorrent` | | DOWNLOAD_CLIENT | No | Download client that is used by *arrs<br>Can be `qbittorrent`, `deluge`, `transmission` or `none` | `qbittorrent` |
| QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 | | QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 |
| QBITTORRENT__USERNAME | No | qBittorrent user | empty | | QBITTORRENT__USERNAME | No | qBittorrent user | empty |
| QBITTORRENT__PASSWORD | No | qBittorrent password | empty | | QBITTORRENT__PASSWORD | No | qBittorrent password | empty |
@@ -193,6 +211,10 @@ regex:<ANY_REGEX> // regex that needs to be marked at the start of the line wi
SONARR__INSTANCES__<NUMBER>__URL SONARR__INSTANCES__<NUMBER>__URL
SONARR__INSTANCES__<NUMBER>__APIKEY SONARR__INSTANCES__<NUMBER>__APIKEY
``` ```
6. Multiple failed import patterns can be specified using this format, where `<NUMBER>` starts from 0:
```
QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>
```
# #
@@ -0,0 +1,9 @@
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public sealed record DownloadClientConfig
{
[ConfigurationKeyName("DOWNLOAD_CLIENT")]
public Enums.DownloadClient DownloadClient { get; init; } = Enums.DownloadClient.QBittorrent;
}
@@ -1,6 +0,0 @@
namespace Common.Configuration;
public static class EnvironmentVariables
{
public const string DownloadClient = "DOWNLOAD_CLIENT";
}
@@ -13,8 +13,17 @@ public sealed record QueueCleanerConfig : IJobConfig
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")] [ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
public ushort ImportFailedMaxStrikes { get; init; } public ushort ImportFailedMaxStrikes { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PRIVATE")]
public bool ImportFailedIgnorePrivate { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PATTERNS")]
public List<string>? ImportFailedIgnorePatterns { get; init; }
[ConfigurationKeyName("STALLED_MAX_STRIKES")] [ConfigurationKeyName("STALLED_MAX_STRIKES")]
public ushort StalledMaxStrikes { get; init; } public ushort StalledMaxStrikes { get; init; }
[ConfigurationKeyName("STALLED_IGNORE_PRIVATE")]
public bool StalledIgnorePrivate { get; init; }
public void Validate() public void Validate()
{ {
@@ -1,8 +1,9 @@
namespace Domain.Enums; namespace Common.Enums;
public enum DownloadClient public enum DownloadClient
{ {
QBittorrent, QBittorrent,
Deluge, Deluge,
Transmission Transmission,
None
} }
+3 -2
View File
@@ -1,6 +1,6 @@
namespace Domain.Models.Arr.Queue; namespace Domain.Models.Arr.Queue;
public record QueueRecord public sealed record QueueRecord
{ {
public int SeriesId { get; init; } public int SeriesId { get; init; }
public int EpisodeId { get; init; } public int EpisodeId { get; init; }
@@ -10,6 +10,7 @@ public record QueueRecord
public string Status { get; init; } public string Status { get; init; }
public string TrackedDownloadStatus { get; init; } public string TrackedDownloadStatus { get; init; }
public string TrackedDownloadState { get; init; } public string TrackedDownloadState { get; init; }
public List<TrackedDownloadStatusMessage>? StatusMessages { get; init; }
public required string DownloadId { get; init; } public required string DownloadId { get; init; }
public required string Protocol { get; init; } public required string Protocol { get; init; }
public required int Id { get; init; } public required int Id { get; init; }
@@ -0,0 +1,8 @@
namespace Domain.Models.Arr.Queue;
public sealed record TrackedDownloadStatusMessage
{
public string Title { get; set; }
public List<string>? Messages { get; set; }
}
@@ -2,11 +2,13 @@
public sealed record TorrentStatus public sealed record TorrentStatus
{ {
public string? Hash { get; set; } public string? Hash { get; init; }
public string? State { get; set; } public string? State { get; init; }
public string? Name { get; set; } public string? Name { get; init; }
public ulong Eta { get; set; } public ulong Eta { get; init; }
public bool Private { get; init; }
} }
@@ -14,6 +14,7 @@ public static class ConfigurationDI
services services
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName)) .Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName)) .Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
.Configure<DownloadClientConfig>(configuration)
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName)) .Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName)) .Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
.Configure<TransmissionConfig>(configuration.GetSection(TransmissionConfig.SectionName)) .Configure<TransmissionConfig>(configuration.GetSection(TransmissionConfig.SectionName))
@@ -18,6 +18,7 @@ public static class ServicesDI
.AddTransient<QueueCleaner>() .AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>() .AddTransient<ContentBlocker>()
.AddTransient<FilenameEvaluator>() .AddTransient<FilenameEvaluator>()
.AddTransient<DummyDownloadService>()
.AddTransient<QBitService>() .AddTransient<QBitService>()
.AddTransient<DelugeService>() .AddTransient<DelugeService>()
.AddTransient<TransmissionService>() .AddTransient<TransmissionService>()
+6 -1
View File
@@ -26,7 +26,12 @@
"Enabled": true, "Enabled": true,
"RunSequentially": true, "RunSequentially": true,
"IMPORT_FAILED_MAX_STRIKES": 5, "IMPORT_FAILED_MAX_STRIKES": 5,
"STALLED_MAX_STRIKES": 5 "IMPORT_FAILED_IGNORE_PRIVATE": true,
"IMPORT_FAILED_IGNORE_PATTERNS": [
"file is a sample"
],
"STALLED_MAX_STRIKES": 5,
"STALLED_IGNORE_PRIVATE": true
}, },
"DOWNLOAD_CLIENT": "qbittorrent", "DOWNLOAD_CLIENT": "qbittorrent",
"qBittorrent": { "qBittorrent": {
+4 -1
View File
@@ -26,7 +26,10 @@
"Enabled": true, "Enabled": true,
"RunSequentially": true, "RunSequentially": true,
"IMPORT_FAILED_MAX_STRIKES": 5, "IMPORT_FAILED_MAX_STRIKES": 5,
"STALLED_MAX_STRIKES": 5 "IMPORT_FAILED_IGNORE_PRIVATE": false,
"IMPORT_FAILED_IGNORE_PATTERNS": [],
"STALLED_MAX_STRIKES": 5,
"STALLED_IGNORE_PRIVATE": false
}, },
"DOWNLOAD_CLIENT": "qbittorrent", "DOWNLOAD_CLIENT": "qbittorrent",
"qBittorrent": { "qBittorrent": {
+43 -2
View File
@@ -65,17 +65,30 @@ public abstract class ArrClient
return queueResponse; return queueResponse;
} }
public virtual bool ShouldRemoveFromQueue(QueueRecord record) public virtual bool ShouldRemoveFromQueue(QueueRecord record, bool isPrivateDownload)
{ {
if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload)
{
// ignore private trackers
_logger.LogDebug("skip failed import check | download is private | {name}", record.Title);
return false;
}
bool hasWarn() => record.TrackedDownloadStatus bool hasWarn() => record.TrackedDownloadStatus
.Equals("warning", StringComparison.InvariantCultureIgnoreCase); .Equals("warning", StringComparison.InvariantCultureIgnoreCase);
bool isImportBlocked() => record.TrackedDownloadState bool isImportBlocked() => record.TrackedDownloadState
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase); .Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
bool isImportPending() => record.TrackedDownloadState bool isImportPending() => record.TrackedDownloadState
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase); .Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
if (hasWarn() && (isImportBlocked() || isImportPending())) if (hasWarn() && (isImportBlocked() || isImportPending()))
{ {
if (HasIgnoredPatterns(record))
{
_logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
return false;
}
return _striker.StrikeAndCheckLimit( return _striker.StrikeAndCheckLimit(
record.DownloadId, record.DownloadId,
record.Title, record.Title,
@@ -134,4 +147,32 @@ public abstract class ArrClient
{ {
request.Headers.Add("x-api-key", apiKey); request.Headers.Add("x-api-key", apiKey);
} }
private bool HasIgnoredPatterns(QueueRecord record)
{
if (_queueCleanerConfig.ImportFailedIgnorePatterns?.Count is null or 0)
{
// no patterns are configured
return false;
}
if (record.StatusMessages?.Count is null or 0)
{
// no status message found
return false;
}
HashSet<string> messages = record.StatusMessages
.SelectMany(x => x.Messages ?? Enumerable.Empty<string>())
.ToHashSet();
record.StatusMessages.Select(x => x.Title)
.ToList()
.ForEach(x => messages.Add(x));
return messages.Any(
m => _queueCleanerConfig.ImportFailedIgnorePatterns.Any(
p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
)
);
}
} }
@@ -1,5 +1,5 @@
using Common.Configuration; using Common.Configuration.Arr;
using Common.Configuration.Arr; using Common.Configuration.DownloadClient;
using Domain.Enums; using Domain.Enums;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.Arr;
@@ -16,6 +16,7 @@ public sealed class ContentBlocker : GenericHandler
public ContentBlocker( public ContentBlocker(
ILogger<ContentBlocker> logger, ILogger<ContentBlocker> logger,
IOptions<DownloadClientConfig> downloadClientConfig,
IOptions<SonarrConfig> sonarrConfig, IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig, IOptions<RadarrConfig> radarrConfig,
SonarrClient sonarrClient, SonarrClient sonarrClient,
@@ -23,13 +24,19 @@ public sealed class ContentBlocker : GenericHandler
ArrQueueIterator arrArrQueueIterator, ArrQueueIterator arrArrQueueIterator,
BlocklistProvider blocklistProvider, BlocklistProvider blocklistProvider,
DownloadServiceFactory downloadServiceFactory DownloadServiceFactory downloadServiceFactory
) : base(logger, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory) ) : base(logger, downloadClientConfig, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory)
{ {
_blocklistProvider = blocklistProvider; _blocklistProvider = blocklistProvider;
} }
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None)
{
_logger.LogWarning("download client is set to none");
return;
}
await _blocklistProvider.LoadBlocklistAsync(); await _blocklistProvider.LoadBlocklistAsync();
await base.ExecuteAsync(); await base.ExecuteAsync();
} }
@@ -30,18 +30,19 @@ public sealed class DelugeService : DownloadServiceBase
await _client.LoginAsync(); await _client.LoginAsync();
} }
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash) public override async Task<RemoveResult> ShouldRemoveFromArrQueueAsync(string hash)
{ {
hash = hash.ToLowerInvariant(); hash = hash.ToLowerInvariant();
DelugeContents? contents = null; DelugeContents? contents = null;
RemoveResult result = new();
TorrentStatus? status = await GetTorrentStatus(hash); TorrentStatus? status = await GetTorrentStatus(hash);
if (status?.Hash is null) if (status?.Hash is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false; return result;
} }
try try
@@ -63,7 +64,10 @@ public sealed class DelugeService : DownloadServiceBase
} }
}); });
return shouldRemove || IsItemStuckAndShouldRemove(status); result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(status);
result.IsPrivate = status.Private;
return result;
} }
public override async Task BlockUnwantedFilesAsync(string hash) public override async Task BlockUnwantedFilesAsync(string hash)
@@ -128,6 +132,18 @@ public sealed class DelugeService : DownloadServiceBase
private bool IsItemStuckAndShouldRemove(TorrentStatus status) private bool IsItemStuckAndShouldRemove(TorrentStatus status)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0)
{
return false;
}
if (_queueCleanerConfig.StalledIgnorePrivate && status.Private)
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
return false;
}
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase)) if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{ {
return false; return false;
@@ -146,7 +162,7 @@ public sealed class DelugeService : DownloadServiceBase
return await _client.SendRequest<TorrentStatus?>( return await _client.SendRequest<TorrentStatus?>(
"web.get_torrent_status", "web.get_torrent_status",
hash, hash,
new[] { "hash", "state", "name", "eta" } new[] { "hash", "state", "name", "eta", "private" }
); );
} }
@@ -31,7 +31,7 @@ public abstract class DownloadServiceBase : IDownloadService
public abstract Task LoginAsync(); public abstract Task LoginAsync();
public abstract Task<bool> ShouldRemoveFromArrQueueAsync(string hash); public abstract Task<RemoveResult> ShouldRemoveFromArrQueueAsync(string hash);
public abstract Task BlockUnwantedFilesAsync(string hash); public abstract Task BlockUnwantedFilesAsync(string hash);
@@ -1,9 +1,7 @@
using Common.Configuration; using Common.Configuration.DownloadClient;
using Common.Configuration.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 Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -12,24 +10,21 @@ namespace Infrastructure.Verticals.DownloadClient;
public sealed class DownloadServiceFactory public sealed class DownloadServiceFactory
{ {
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly Domain.Enums.DownloadClient _downloadClient; private readonly Common.Enums.DownloadClient _downloadClient;
public DownloadServiceFactory(IServiceProvider serviceProvider, IConfiguration configuration) public DownloadServiceFactory(IServiceProvider serviceProvider, IOptions<DownloadClientConfig> downloadClientConfig)
{ {
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_downloadClient = (Domain.Enums.DownloadClient)Enum.Parse( _downloadClient = downloadClientConfig.Value.DownloadClient;
typeof(Domain.Enums.DownloadClient),
configuration[EnvironmentVariables.DownloadClient] ?? Domain.Enums.DownloadClient.QBittorrent.ToString(),
true
);
} }
public IDownloadService CreateDownloadClient() => public IDownloadService CreateDownloadClient() =>
_downloadClient switch _downloadClient switch
{ {
Domain.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService<QBitService>(), Common.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService<QBitService>(),
Domain.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(), Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(),
Domain.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(), Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(),
Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService<DummyDownloadService>(),
_ => throw new ArgumentOutOfRangeException() _ => throw new ArgumentOutOfRangeException()
}; };
} }
@@ -0,0 +1,33 @@
using Common.Configuration.QueueCleaner;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient;
public sealed class DummyDownloadService : DownloadServiceBase
{
public DummyDownloadService(ILogger<DownloadServiceBase> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, FilenameEvaluator filenameEvaluator, Striker striker) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
{
}
public override void Dispose()
{
}
public override Task LoginAsync()
{
return Task.CompletedTask;
}
public override Task<RemoveResult> ShouldRemoveFromArrQueueAsync(string hash)
{
throw new NotImplementedException();
}
public override Task BlockUnwantedFilesAsync(string hash)
{
throw new NotImplementedException();
}
}
@@ -4,7 +4,7 @@ public interface IDownloadService : IDisposable
{ {
public Task LoginAsync(); public Task LoginAsync();
public Task<bool> ShouldRemoveFromArrQueueAsync(string hash); public Task<RemoveResult> ShouldRemoveFromArrQueueAsync(string hash);
public Task BlockUnwantedFilesAsync(string hash); public Task BlockUnwantedFilesAsync(string hash);
} }
@@ -1,4 +1,4 @@
using Common.Configuration.DownloadClient; using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
@@ -36,21 +36,35 @@ public sealed class QBitService : DownloadServiceBase
await _client.LoginAsync(_config.Username, _config.Password); await _client.LoginAsync(_config.Username, _config.Password);
} }
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash) public override async Task<RemoveResult> ShouldRemoveFromArrQueueAsync(string hash)
{ {
RemoveResult result = new();
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
.FirstOrDefault(); .FirstOrDefault();
if (torrent is null) if (torrent is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false; return result;
} }
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
return result;
}
result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
bool.TryParse(dictValue?.ToString(), out bool boolValue)
&& boolValue;
// if all files were blocked by qBittorrent // if all files were blocked by qBittorrent
if (torrent is { CompletionOn: not null, Downloaded: null or 0 }) if (torrent is { CompletionOn: not null, Downloaded: null or 0 })
{ {
return true; result.ShouldRemove = true;
return result;
} }
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash); IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
@@ -58,10 +72,13 @@ public sealed class QBitService : DownloadServiceBase
// if all files are marked as skip // if all files are marked as skip
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
{ {
return true; result.ShouldRemove = true;
return result;
} }
return IsItemStuckAndShouldRemove(torrent); result.ShouldRemove = IsItemStuckAndShouldRemove(torrent, result.IsPrivate);
return result;
} }
public override async Task BlockUnwantedFilesAsync(string hash) public override async Task BlockUnwantedFilesAsync(string hash)
@@ -95,8 +112,20 @@ public sealed class QBitService : DownloadServiceBase
_client.Dispose(); _client.Dispose();
} }
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent) private bool IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0)
{
return false;
}
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
return false;
}
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
and not TorrentState.ForcedFetchingMetadata) and not TorrentState.ForcedFetchingMetadata)
{ {
@@ -0,0 +1,14 @@
namespace Infrastructure.Verticals.DownloadClient;
public sealed record RemoveResult
{
/// <summary>
/// True if the download should be removed; otherwise false.
/// </summary>
public bool ShouldRemove { get; set; }
/// <summary>
/// True if the download is private; otherwise false.
/// </summary>
public bool IsPrivate { get; set; }
}
@@ -38,17 +38,19 @@ public sealed class TransmissionService : DownloadServiceBase
await _client.GetSessionInformationAsync(); await _client.GetSessionInformationAsync();
} }
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash) public override async Task<RemoveResult> ShouldRemoveFromArrQueueAsync(string hash)
{ {
RemoveResult result = new();
TorrentInfo? torrent = await GetTorrentAsync(hash); TorrentInfo? torrent = await GetTorrentAsync(hash);
if (torrent is null) if (torrent is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false; return result;
} }
bool shouldRemove = torrent.FileStats?.Length > 0; bool shouldRemove = torrent.FileStats?.Length > 0;
result.IsPrivate = torrent.IsPrivate ?? false;
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? []) foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
{ {
@@ -65,8 +67,10 @@ public sealed class TransmissionService : DownloadServiceBase
} }
} }
// remove if all files are unwanted // remove if all files are unwanted or download is stuck
return shouldRemove || IsItemStuckAndShouldRemove(torrent); result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(torrent);
return result;
} }
public override async Task BlockUnwantedFilesAsync(string hash) public override async Task BlockUnwantedFilesAsync(string hash)
@@ -116,6 +120,18 @@ public sealed class TransmissionService : DownloadServiceBase
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent) private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0)
{
return false;
}
if (_queueCleanerConfig.StalledIgnorePrivate && (torrent.IsPrivate ?? false))
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
return false;
}
if (torrent.Status is not 4) if (torrent.Status is not 4)
{ {
// not in downloading state // not in downloading state
@@ -144,7 +160,8 @@ public sealed class TransmissionService : DownloadServiceBase
TorrentFields.ID, TorrentFields.ID,
TorrentFields.ETA, TorrentFields.ETA,
TorrentFields.NAME, TorrentFields.NAME,
TorrentFields.STATUS TorrentFields.STATUS,
TorrentFields.IS_PRIVATE
]; ];
// refresh cache // refresh cache
@@ -1,17 +1,19 @@
using Common.Configuration.Arr; using Common.Configuration.Arr;
using Common.Configuration.DownloadClient;
using Domain.Enums; using Domain.Enums;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.DownloadClient;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.Jobs; namespace Infrastructure.Verticals.Jobs;
public abstract class GenericHandler : IDisposable public abstract class GenericHandler : IDisposable
{ {
protected readonly ILogger<GenericHandler> _logger; protected readonly ILogger<GenericHandler> _logger;
protected readonly DownloadClientConfig _downloadClientConfig;
protected readonly SonarrConfig _sonarrConfig; protected readonly SonarrConfig _sonarrConfig;
protected readonly RadarrConfig _radarrConfig; protected readonly RadarrConfig _radarrConfig;
protected readonly SonarrClient _sonarrClient; protected readonly SonarrClient _sonarrClient;
@@ -21,6 +23,7 @@ public abstract class GenericHandler : IDisposable
protected GenericHandler( protected GenericHandler(
ILogger<GenericHandler> logger, ILogger<GenericHandler> logger,
IOptions<DownloadClientConfig> downloadClientConfig,
SonarrConfig sonarrConfig, SonarrConfig sonarrConfig,
RadarrConfig radarrConfig, RadarrConfig radarrConfig,
SonarrClient sonarrClient, SonarrClient sonarrClient,
@@ -30,6 +33,7 @@ public abstract class GenericHandler : IDisposable
) )
{ {
_logger = logger; _logger = logger;
_downloadClientConfig = downloadClientConfig.Value;
_sonarrConfig = sonarrConfig; _sonarrConfig = sonarrConfig;
_radarrConfig = radarrConfig; _radarrConfig = radarrConfig;
_sonarrClient = sonarrClient; _sonarrClient = sonarrClient;
@@ -1,5 +1,5 @@
using Common.Configuration.Arr; using Common.Configuration.Arr;
using Common.Configuration.QueueCleaner; using Common.Configuration.DownloadClient;
using Domain.Enums; using Domain.Enums;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
@@ -15,13 +15,14 @@ public sealed class QueueCleaner : GenericHandler
{ {
public QueueCleaner( public QueueCleaner(
ILogger<QueueCleaner> logger, ILogger<QueueCleaner> logger,
IOptions<DownloadClientConfig> downloadClientConfig,
IOptions<SonarrConfig> sonarrConfig, IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig, IOptions<RadarrConfig> radarrConfig,
SonarrClient sonarrClient, SonarrClient sonarrClient,
RadarrClient radarrClient, RadarrClient radarrClient,
ArrQueueIterator arrArrQueueIterator, ArrQueueIterator arrArrQueueIterator,
DownloadServiceFactory downloadServiceFactory DownloadServiceFactory downloadServiceFactory
) : base(logger, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory) ) : base(logger, downloadClientConfig, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory)
{ {
} }
@@ -56,7 +57,16 @@ public sealed class QueueCleaner : GenericHandler
continue; continue;
} }
if (!arrClient.ShouldRemoveFromQueue(record) && !await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId)) RemoveResult removeResult = new();
if (_downloadClientConfig.DownloadClient is not Common.Enums.DownloadClient.None)
{
removeResult = await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId);
}
bool shouldRemoveFromArr = arrClient.ShouldRemoveFromQueue(record, removeResult.IsPrivate);
if (!shouldRemoveFromArr && !removeResult.ShouldRemove)
{ {
_logger.LogInformation("skip | {title}", record.Title); _logger.LogInformation("skip | {title}", record.Title);
continue; continue;
+3
View File
@@ -182,7 +182,10 @@ services:
- QUEUECLEANER__ENABLED=true - QUEUECLEANER__ENABLED=true
- QUEUECLEANER__RUNSEQUENTIALLY=true - QUEUECLEANER__RUNSEQUENTIALLY=true
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5 - QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=true
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=file is a sample
- QUEUECLEANER__STALLED_MAX_STRIKES=5 - QUEUECLEANER__STALLED_MAX_STRIKES=5
- QUEUECLEANER__STALLED_IGNORE_PRIVATE=true
- CONTENTBLOCKER__ENABLED=true - CONTENTBLOCKER__ENABLED=true
- CONTENTBLOCKER__BLACKLIST__ENABLED=true - CONTENTBLOCKER__BLACKLIST__ENABLED=true