Remove stalled downloads (#21)

This commit is contained in:
Marius Nechifor
2024-12-16 23:20:32 +02:00
parent 0a6ec21c95
commit 64bb9fc513
53 changed files with 522 additions and 160 deletions
+59 -5
View File
@@ -1,8 +1,10 @@
using Common.Configuration;
using Common.Configuration.Arr;
using Common.Configuration.Arr;
using Common.Configuration.Logging;
using Domain.Arr.Queue;
using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -14,17 +16,28 @@ public abstract class ArrClient
protected readonly ILogger<ArrClient> _logger;
protected readonly HttpClient _httpClient;
protected readonly LoggingConfig _loggingConfig;
protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly Striker _striker;
protected ArrClient(ILogger<ArrClient> logger, IHttpClientFactory httpClientFactory, IOptions<LoggingConfig> loggingConfig)
protected ArrClient(
ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker
)
{
_logger = logger;
_striker = striker;
_httpClient = httpClientFactory.CreateClient();
_loggingConfig = loggingConfig.Value;
_queueCleanerConfig = queueCleanerConfig.Value;
_striker = striker;
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{
Uri uri = new(arrInstance.Url, $"/api/v3/queue?page={page}&pageSize=200&sortKey=timeleft");
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page));
using HttpRequestMessage request = new(HttpMethod.Get, uri);
SetApiKey(request, arrInstance.ApiKey);
@@ -52,6 +65,28 @@ public abstract class ArrClient
return queueResponse;
}
public virtual bool ShouldRemoveFromQueue(QueueRecord record)
{
bool hasWarn() => record.TrackedDownloadStatus
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
bool isImportBlocked() => record.TrackedDownloadState
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
bool isImportPending() => record.TrackedDownloadState
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
if (hasWarn() && (isImportBlocked() || isImportPending()))
{
return _striker.StrikeAndCheckLimit(
record.DownloadId,
record.Title,
_queueCleanerConfig.ImportFailedMaxStrikes,
StrikeType.ImportFailed
);
}
return false;
}
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord queueRecord)
{
Uri uri = new(arrInstance.Url, $"/api/v3/queue/{queueRecord.Id}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false");
@@ -76,6 +111,25 @@ public abstract class ArrClient
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items);
public virtual bool IsRecordValid(QueueRecord record)
{
if (string.IsNullOrEmpty(record.DownloadId))
{
_logger.LogDebug("skip | download id is null for {title}", record.Title);
return false;
}
if (record.DownloadId.Equals(record.Title, StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogDebug("skip | item is not ready yet | {title}", record.Title);
return false;
}
return true;
}
protected abstract string GetQueueUrlPath(int page);
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{
request.Headers.Add("x-api-key", apiKey);
@@ -1,6 +1,6 @@
using Common.Configuration;
using Common.Configuration.Arr;
using Domain.Arr.Queue;
using Domain.Models.Arr.Queue;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Arr;
@@ -1,8 +1,12 @@
using System.Text;
using Common.Configuration.Arr;
using Common.Configuration.Logging;
using Common.Configuration.QueueCleaner;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Domain.Models.Radarr;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -14,11 +18,18 @@ public sealed class RadarrClient : ArrClient
public RadarrClient(
ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig
) : base(logger, httpClientFactory, loggingConfig)
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
{
}
protected override string GetQueueUrlPath(int page)
{
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
@@ -59,6 +70,17 @@ public sealed class RadarrClient : ArrClient
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.MovieId is 0)
{
_logger.LogDebug("skip | item information missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
{
string status = success ? "triggered" : "failed";
@@ -1,8 +1,12 @@
using System.Text;
using Common.Configuration.Arr;
using Common.Configuration.Logging;
using Common.Configuration.QueueCleaner;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Domain.Models.Sonarr;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -14,10 +18,17 @@ public sealed class SonarrClient : ArrClient
public SonarrClient(
ILogger<SonarrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig
) : base(logger, httpClientFactory, loggingConfig)
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker)
{
}
protected override string GetQueueUrlPath(int page)
{
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true";
}
public override async Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items)
{
@@ -26,11 +37,9 @@ public sealed class SonarrClient : ArrClient
return;
}
SonarrConfig sonarrConfig = (SonarrConfig)config;
Uri uri = new(arrInstance.Url, "/api/v3/command");
foreach (SonarrCommand command in GetSearchCommands(sonarrConfig.SearchType, items))
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uri);
request.Content = new StringContent(
@@ -41,22 +50,33 @@ public sealed class SonarrClient : ArrClient
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, sonarrConfig.SearchType);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
try
{
response.EnsureSuccessStatusCode();
_logger.LogInformation("{log}", GetSearchLog(sonarrConfig.SearchType, arrInstance.Url, command, true, logContext));
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(sonarrConfig.SearchType, arrInstance.Url, command, false, logContext));
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
throw;
}
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.EpisodeId is 0 || record.SeriesId is 0)
{
_logger.LogDebug("skip | item information missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(
SonarrSearchType searchType,
Uri instanceUrl,
@@ -191,7 +211,7 @@ public sealed class SonarrClient : ArrClient
return JsonConvert.DeserializeObject<Series>(responseBody);
}
private List<SonarrCommand> GetSearchCommands(SonarrSearchType searchType, HashSet<SearchItem> items)
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
{
const string episodeSearch = "EpisodeSearch";
const string seasonSearch = "SeasonSearch";
@@ -199,13 +219,13 @@ public sealed class SonarrClient : ArrClient
List<SonarrCommand> commands = new();
foreach (SearchItem item in items)
foreach (SonarrSearchItem item in items)
{
SonarrCommand command = searchType is SonarrSearchType.Episode
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
: new();
switch (searchType)
switch (item.SearchType)
{
case SonarrSearchType.Episode when command.EpisodeIds is null:
command.EpisodeIds = [item.Id];
@@ -227,15 +247,16 @@ public sealed class SonarrClient : ArrClient
break;
default:
throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null);
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
}
if (searchType is SonarrSearchType.Episode && commands.Count > 0)
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
{
// only one command will be generated for episodes search
continue;
}
command.SearchType = item.SearchType;
commands.Add(command);
}
@@ -1,7 +1,7 @@
using Common.Configuration;
using Common.Configuration.Arr;
using Domain.Arr.Queue;
using Domain.Enums;
using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Jobs;
@@ -58,12 +58,4 @@ public sealed class ContentBlocker : GenericHandler
}
});
}
private ArrClient GetClient(InstanceType type) =>
type switch
{
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}
@@ -1,44 +1,46 @@
using Common.Configuration;
using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Domain.Models.Deluge.Response;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient.Deluge;
public sealed class DelugeService : IDownloadService
public sealed class DelugeService : DownloadServiceBase
{
private readonly ILogger<DelugeService> _logger;
private readonly DelugeClient _client;
private readonly FilenameEvaluator _filenameEvaluator;
public DelugeService(
ILogger<DelugeService> logger,
IOptions<DelugeConfig> config,
IHttpClientFactory httpClientFactory,
FilenameEvaluator filenameEvaluator
)
IOptions<QueueCleanerConfig> queueCleanerConfig,
FilenameEvaluator filenameEvaluator,
Striker striker
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
{
_logger = logger;
config.Value.Validate();
_client = new (config, httpClientFactory);
_filenameEvaluator = filenameEvaluator;
}
public async Task LoginAsync()
public override async Task LoginAsync()
{
await _client.LoginAsync();
}
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
{
hash = hash.ToLowerInvariant();
DelugeContents? contents = null;
TorrentStatus? status = await GetTorrentStatus(hash);
if (!await HasMinimalStatus(hash))
if (status?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false;
}
@@ -51,13 +53,7 @@ public sealed class DelugeService : IDownloadService
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
}
// if no files found, torrent might be stuck in Downloading metadata
if (contents?.Contents?.Count is null or 0)
{
return false;
}
bool shouldRemove = true;
bool shouldRemove = contents?.Contents?.Count > 0;
ProcessFiles(contents.Contents, (_, file) =>
{
@@ -67,15 +63,18 @@ public sealed class DelugeService : IDownloadService
}
});
return shouldRemove;
return shouldRemove || IsItemStuckAndShouldRemove(status);
}
public async Task BlockUnwantedFilesAsync(string hash)
public override async Task BlockUnwantedFilesAsync(string hash)
{
hash = hash.ToLowerInvariant();
if (!await HasMinimalStatus(hash))
TorrentStatus? status = await GetTorrentStatus(hash);
if (status?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return;
}
@@ -126,22 +125,29 @@ public sealed class DelugeService : IDownloadService
await _client.ChangeFilesPriority(hash, sortedPriorities);
}
private async Task<bool> HasMinimalStatus(string hash)
private bool IsItemStuckAndShouldRemove(TorrentStatus status)
{
DelugeMinimalStatus? status = await _client.SendRequest<DelugeMinimalStatus?>(
"web.get_torrent_status",
hash,
new[] { "hash" }
);
if (status?.Hash is null)
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false;
}
return true;
if (status.Eta > 0)
{
return false;
}
return StrikeAndCheckLimit(status.Hash!, status.Name!);
}
private async Task<TorrentStatus?> GetTorrentStatus(string hash)
{
return await _client.SendRequest<TorrentStatus?>(
"web.get_torrent_status",
hash,
new[] { "hash", "state", "name", "eta" }
);
}
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory> contents, Action<string, DelugeFileOrDirectory> processFile)
@@ -161,7 +167,7 @@ public sealed class DelugeService : IDownloadService
}
}
public void Dispose()
public override void Dispose()
{
}
}
@@ -0,0 +1,42 @@
using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient;
public abstract class DownloadServiceBase : IDownloadService
{
protected readonly ILogger<DownloadServiceBase> _logger;
protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly FilenameEvaluator _filenameEvaluator;
protected readonly Striker _striker;
protected DownloadServiceBase(
ILogger<DownloadServiceBase> logger,
IOptions<QueueCleanerConfig> queueCleanerConfig,
FilenameEvaluator filenameEvaluator,
Striker striker
)
{
_logger = logger;
_queueCleanerConfig = queueCleanerConfig.Value;
_filenameEvaluator = filenameEvaluator;
_striker = striker;
}
public abstract void Dispose();
public abstract Task LoginAsync();
public abstract Task<bool> ShouldRemoveFromArrQueueAsync(string hash);
public abstract Task BlockUnwantedFilesAsync(string hash);
protected bool StrikeAndCheckLimit(string hash, string itemName)
{
return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
}
}
@@ -1,33 +1,32 @@
using Common.Configuration;
using Common.Configuration.DownloadClient;
using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using QBittorrent.Client;
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
public sealed class QBitService : IDownloadService
public sealed class QBitService : DownloadServiceBase
{
private readonly ILogger<QBitService> _logger;
private readonly QBitConfig _config;
private readonly QBittorrentClient _client;
private readonly FilenameEvaluator _filenameEvaluator;
public QBitService(
ILogger<QBitService> logger,
IOptions<QBitConfig> config,
FilenameEvaluator filenameEvaluator
)
IOptions<QueueCleanerConfig> queueCleanerConfig,
FilenameEvaluator filenameEvaluator,
Striker striker
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
{
_logger = logger;
_config = config.Value;
_config.Validate();
_client = new(_config.Url);
_filenameEvaluator = filenameEvaluator;
}
public async Task LoginAsync()
public override async Task LoginAsync()
{
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
{
@@ -37,13 +36,14 @@ public sealed class QBitService : IDownloadService
await _client.LoginAsync(_config.Username, _config.Password);
}
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
{
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
.FirstOrDefault();
if (torrent is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false;
}
@@ -55,17 +55,16 @@ public sealed class QBitService : IDownloadService
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
// if no files found, torrent might be stuck in Downloading metadata
if (files?.Count is null or 0)
// if all files are marked as skip
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
{
return false;
return true;
}
// if all files are marked as skip
return files.All(x => x.Priority is TorrentContentPriority.Skip);
return IsItemStuckAndShouldRemove(torrent);
}
public async Task BlockUnwantedFilesAsync(string hash)
public override async Task BlockUnwantedFilesAsync(string hash)
{
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
@@ -91,8 +90,20 @@ public sealed class QBitService : IDownloadService
}
}
public void Dispose()
public override void Dispose()
{
_client.Dispose();
}
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
{
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
and not TorrentState.ForcedFetchingMetadata)
{
// ignore other states
return false;
}
return StrikeAndCheckLimit(torrent.Hash, torrent.Name);
}
}
@@ -1,6 +1,7 @@
using Common.Configuration;
using Common.Configuration.DownloadClient;
using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Transmission.API.RPC;
@@ -9,21 +10,20 @@ using Transmission.API.RPC.Entity;
namespace Infrastructure.Verticals.DownloadClient.Transmission;
public sealed class TransmissionService : IDownloadService
public sealed class TransmissionService : DownloadServiceBase
{
private readonly ILogger<TransmissionService> _logger;
private readonly TransmissionConfig _config;
private readonly Client _client;
private readonly FilenameEvaluator _filenameEvaluator;
private TorrentInfo[]? _torrentsCache;
public TransmissionService(
ILogger<TransmissionService> logger,
IOptions<TransmissionConfig> config,
FilenameEvaluator filenameEvaluator
)
IOptions<QueueCleanerConfig> queueCleanerConfig,
FilenameEvaluator filenameEvaluator,
Striker striker
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
{
_logger = logger;
_config = config.Value;
_config.Validate();
_client = new(
@@ -31,44 +31,45 @@ public sealed class TransmissionService : IDownloadService
login: _config.Username,
password: _config.Password
);
_filenameEvaluator = filenameEvaluator;
}
public async Task LoginAsync()
public override async Task LoginAsync()
{
await _client.GetSessionInformationAsync();
}
public async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
{
TorrentInfo? torrent = await GetTorrentAsync(hash);
// if no files found, torrent might be stuck in Downloading metadata
if (torrent?.FileStats?.Length is null or 0)
if (torrent is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return false;
}
bool shouldRemove = torrent.FileStats?.Length > 0;
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
{
if (!stats.Wanted.HasValue)
{
// if any files stats are missing, do not remove
return false;
shouldRemove = false;
}
if (stats.Wanted.HasValue && stats.Wanted.Value)
{
// if any files are wanted, do not remove
return false;
shouldRemove = false;
}
}
// remove if all files are unwanted
return true;
return shouldRemove || IsItemStuckAndShouldRemove(torrent);
}
public async Task BlockUnwantedFilesAsync(string hash)
public override async Task BlockUnwantedFilesAsync(string hash)
{
TorrentInfo? torrent = await GetTorrentAsync(hash);
@@ -108,10 +109,26 @@ public sealed class TransmissionService : IDownloadService
FilesUnwanted = unwantedFiles.ToArray(),
});
}
public void Dispose()
public override void Dispose()
{
}
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
{
if (torrent.Status is not 4)
{
// not in downloading state
return false;
}
if (torrent.Eta > 0)
{
return false;
}
return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!);
}
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
{
@@ -120,7 +137,15 @@ public sealed class TransmissionService : IDownloadService
if (_torrentsCache is null || torrent is null)
{
string[] fields = [TorrentFields.FILES, TorrentFields.FILE_STATS, TorrentFields.HASH_STRING, TorrentFields.ID];
string[] fields = [
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS
];
// refresh cache
_torrentsCache = (await _client.TorrentGetAsync(fields))
@@ -0,0 +1,57 @@
using Domain.Enums;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.ItemStriker;
public class Striker
{
private readonly ILogger<Striker> _logger;
private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions;
public Striker(ILogger<Striker> logger, IMemoryCache cache)
{
_logger = logger;
_cache = cache;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromHours(2));
}
public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
{
if (maxStrikes is 0)
{
return false;
}
string key = $"{strikeType.ToString()}_{hash}";
if (!_cache.TryGetValue(key, out int? strikeCount))
{
strikeCount = 1;
}
else
{
++strikeCount;
}
_logger.LogDebug("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
_cache.Set(key, strikeCount, _cacheOptions);
if (strikeCount < maxStrikes)
{
return false;
}
if (strikeCount > maxStrikes)
{
_logger.LogWarning("blocked item keeps coming back | {name}", itemName);
_logger.LogWarning("be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items");
}
_logger.LogInformation("removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName);
return true;
}
}
@@ -1,10 +1,11 @@
using Common.Configuration.Arr;
using Domain.Arr.Queue;
using Domain.Enums;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.DownloadClient;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Jobs;
@@ -88,18 +89,27 @@ public abstract class GenericHandler : IDisposable
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record) =>
type switch
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
{
return type switch
{
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode => new SonarrSearchItem
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && !isPack => new SonarrSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId
SeriesId = record.SeriesId,
SearchType = SonarrSearchType.Episode
},
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && isPack => new SonarrSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId,
SearchType = SonarrSearchType.Season
},
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId
SeriesId = record.SeriesId,
SearchType = SonarrSearchType.Series
},
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem
{
@@ -111,4 +121,5 @@ public abstract class GenericHandler : IDisposable
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}
}
@@ -1,8 +1,8 @@
using Common.Configuration;
using Common.Configuration.Arr;
using Domain.Arr.Queue;
using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Jobs;
@@ -29,34 +29,45 @@ public sealed class QueueCleaner : GenericHandler
{
HashSet<SearchItem> itemsToBeRefreshed = [];
ArrClient arrClient = GetClient(instanceType);
ArrConfig arrConfig = GetConfig(instanceType);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
foreach (QueueRecord record in items)
var groups = items
.GroupBy(x => x.DownloadId)
.ToList();
foreach (var group in groups)
{
if (group.Any(x => !arrClient.IsRecordValid(x)))
{
continue;
}
QueueRecord record = group.First();
if (record.Protocol is not "torrent")
{
continue;
}
if (string.IsNullOrEmpty(record.DownloadId))
if (!arrClient.IsRecordValid(record))
{
_logger.LogDebug("skip | download id is null for {title}", record.Title);
continue;
}
if (!await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
if (!arrClient.ShouldRemoveFromQueue(record) && !await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
{
_logger.LogInformation("skip | {title}", record.Title);
continue;
}
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record));
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
await arrClient.DeleteQueueItemAsync(instance, record);
}
});
await arrClient.RefreshItemsAsync(instance, GetConfig(instanceType), itemsToBeRefreshed);
await arrClient.RefreshItemsAsync(instance, arrConfig, itemsToBeRefreshed);
}
}