Compare commits

...

4 Commits

Author SHA1 Message Date
Flaminel 5fe0f5750a Fix qBit queued items being processed (#102) 2025-03-21 23:06:31 +02:00
Flaminel b8ce225ccc Fix Deluge service crashing when download is not found (#97) 2025-03-20 00:09:58 +02:00
Flaminel f21f7388b7 Add download client customizable url base (#43) 2025-03-20 00:09:24 +02:00
Flaminel a1354f231a Add base path support for arrs (#96) 2025-03-20 00:08:51 +02:00
23 changed files with 247 additions and 116 deletions
+3
View File
@@ -214,15 +214,18 @@ services:
# OR # OR
# - DOWNLOAD_CLIENT=qBittorrent # - DOWNLOAD_CLIENT=qBittorrent
# - QBITTORRENT__URL=http://localhost:8080 # - QBITTORRENT__URL=http://localhost:8080
# - QBITTORRENT__URL_BASE=myCustomPath
# - QBITTORRENT__USERNAME=user # - QBITTORRENT__USERNAME=user
# - QBITTORRENT__PASSWORD=pass # - QBITTORRENT__PASSWORD=pass
# OR # OR
# - DOWNLOAD_CLIENT=deluge # - DOWNLOAD_CLIENT=deluge
# - DELUGE__URL_BASE=myCustomPath
# - DELUGE__URL=http://localhost:8112 # - DELUGE__URL=http://localhost:8112
# - DELUGE__PASSWORD=testing # - DELUGE__PASSWORD=testing
# OR # OR
# - DOWNLOAD_CLIENT=transmission # - DOWNLOAD_CLIENT=transmission
# - TRANSMISSION__URL=http://localhost:9091 # - TRANSMISSION__URL=http://localhost:9091
# - TRANSMISSION__URL_BASE=myCustomPath
# - TRANSMISSION__USERNAME=test # - TRANSMISSION__USERNAME=test
# - TRANSMISSION__PASSWORD=testing # - TRANSMISSION__PASSWORD=testing
@@ -1,4 +1,5 @@
using Common.Exceptions; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient; namespace Common.Configuration.DownloadClient;
@@ -8,6 +9,9 @@ public sealed record DelugeConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Password { get; init; } public string? Password { get; init; }
public void Validate() public void Validate()
@@ -1,4 +1,5 @@
using Common.Exceptions; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient; namespace Common.Configuration.DownloadClient;
@@ -8,6 +9,9 @@ public sealed class QBitConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Username { get; init; } public string? Username { get; init; }
public string? Password { get; init; } public string? Password { get; init; }
@@ -1,4 +1,5 @@
using Common.Exceptions; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient; namespace Common.Configuration.DownloadClient;
@@ -8,6 +9,9 @@ public record TransmissionConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = "transmission";
public string? Username { get; init; } public string? Username { get; init; }
public string? Password { get; init; } public string? Password { get; init; }
+5 -1
View File
@@ -2,7 +2,11 @@
public enum DeleteReason public enum DeleteReason
{ {
None,
Stalled, Stalled,
ImportFailed, ImportFailed,
AllFilesBlocked DownloadingMetadata,
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
} }
+1
View File
@@ -3,5 +3,6 @@
public enum StrikeType public enum StrikeType
{ {
Stalled, Stalled,
DownloadingMetadata,
ImportFailed ImportFailed
} }
@@ -52,15 +52,18 @@
"DOWNLOAD_CLIENT": "qbittorrent", "DOWNLOAD_CLIENT": "qbittorrent",
"qBittorrent": { "qBittorrent": {
"Url": "http://localhost:8080", "Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
"Deluge": { "Deluge": {
"Url": "http://localhost:8112", "Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing" "Password": "testing"
}, },
"Transmission": { "Transmission": {
"Url": "http://localhost:9091", "Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
+3
View File
@@ -42,15 +42,18 @@
"DOWNLOAD_CLIENT": "none", "DOWNLOAD_CLIENT": "none",
"qBittorrent": { "qBittorrent": {
"Url": "http://localhost:8080", "Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "", "Username": "",
"Password": "" "Password": ""
}, },
"Deluge": { "Deluge": {
"Url": "http://localhost:8112", "Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing" "Password": "testing"
}, },
"Transmission": { "Transmission": {
"Url": "http://localhost:9091", "Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
@@ -105,13 +105,14 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
// Arrange // Arrange
const string hash = "test-hash"; const string hash = "test-hash";
const string itemName = "test-item"; const string itemName = "test-item";
_fixture.Striker.StrikeAndCheckLimit(hash, itemName, 3, StrikeType.Stalled) StrikeType strikeType = StrikeType.Stalled;
_fixture.Striker.StrikeAndCheckLimit(hash, itemName, 3, strikeType)
.Returns(true); .Returns(true);
TestDownloadService sut = _fixture.CreateSut(); TestDownloadService sut = _fixture.CreateSut();
// Act // Act
bool result = await sut.StrikeAndCheckLimit(hash, itemName); bool result = await sut.StrikeAndCheckLimit(hash, itemName, strikeType);
// Assert // Assert
result.ShouldBeTrue(); result.ShouldBeTrue();
@@ -3,6 +3,7 @@ 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 Domain.Enums;
using Infrastructure.Interceptors; using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.DownloadClient;
@@ -45,6 +46,6 @@ public class TestDownloadService : DownloadService
// 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) => base.StrikeAndCheckLimit(hash, itemName); 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, Category category) => base.ShouldCleanDownload(ratio, seedingTime, category);
} }
+26 -12
View File
@@ -43,9 +43,11 @@ public abstract class ArrClient : IArrClient
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page) public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{ {
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page)); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
using HttpRequestMessage request = new(HttpMethod.Get, uri); using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -56,7 +58,7 @@ public abstract class ArrClient : IArrClient
} }
catch catch
{ {
_logger.LogError("queue list failed | {uri}", uri); _logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
throw; throw;
} }
@@ -65,7 +67,7 @@ public abstract class ArrClient : IArrClient
if (queueResponse is null) if (queueResponse is null)
{ {
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}"); throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
} }
return queueResponse; return queueResponse;
@@ -112,13 +114,20 @@ public abstract class ArrClient : IArrClient
return false; return false;
} }
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient) public virtual async Task DeleteQueueItemAsync(
ArrInstance arrInstance,
QueueRecord record,
bool removeFromClient,
DeleteReason deleteReason
)
{ {
Uri uri = new(arrInstance.Url, GetQueueDeleteUrlPath(record.Id, removeFromClient)); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueDeleteUrlPath(record.Id).TrimStart('/')}";
uriBuilder.Query = GetQueueDeleteUrlQuery(removeFromClient);
try try
{ {
using HttpRequestMessage request = new(HttpMethod.Delete, uri); using HttpRequestMessage request = new(HttpMethod.Delete, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request); HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
@@ -126,15 +135,16 @@ public abstract class ArrClient : IArrClient
_logger.LogInformation( _logger.LogInformation(
removeFromClient removeFromClient
? "queue item deleted | {url} | {title}" ? "queue item deleted with reason {reason} | {url} | {title}"
: "queue item removed from arr | {url} | {title}", : "queue item removed from arr with reason {reason} | {url} | {title}",
deleteReason.ToString(),
arrInstance.Url, arrInstance.Url,
record.Title record.Title
); );
} }
catch catch
{ {
_logger.LogError("queue delete failed | {uri} | {title}", uri, record.Title); _logger.LogError("queue delete failed | {uri} | {title}", uriBuilder.Uri, record.Title);
throw; throw;
} }
} }
@@ -152,9 +162,13 @@ public abstract class ArrClient : IArrClient
return true; return true;
} }
protected abstract string GetQueueUrlPath(int page); protected abstract string GetQueueUrlPath();
protected abstract string GetQueueDeleteUrlPath(long recordId, bool removeFromClient); protected abstract string GetQueueUrlQuery(int page);
protected abstract string GetQueueDeleteUrlPath(long recordId);
protected abstract string GetQueueDeleteUrlQuery(bool removeFromClient);
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey) protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{ {
@@ -11,7 +11,7 @@ public interface IArrClient
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload); Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient); Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items); Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
@@ -27,29 +27,42 @@ public class LidarrClient : ArrClient, ILidarrClient
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v1/queue?page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true"; return "/api/v1/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v1/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{ {
if (items?.Count is null or 0) return; if (items?.Count is null or 0)
{
return;
}
Uri uri = new(arrInstance.Url, "/api/v1/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
foreach (var command in GetSearchCommands(items)) foreach (var command in GetSearchCommands(items))
{ {
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8, Encoding.UTF8,
@@ -132,8 +145,11 @@ public class LidarrClient : ArrClient, ILidarrClient
private async Task<List<Album>?> GetAlbumsAsync(ArrInstance arrInstance, List<long> albumIds) private async Task<List<Album>?> GetAlbumsAsync(ArrInstance arrInstance, List<long> albumIds)
{ {
Uri uri = new(arrInstance.Url, $"api/v1/album?{string.Join('&', albumIds.Select(x => $"albumIds={x}"))}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album";
uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using var response = await _httpClient.SendAsync(request); using var response = await _httpClient.SendAsync(request);
@@ -27,18 +27,27 @@ public class RadarrClient : ArrClient, IRadarrClient
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true"; return "/api/v3/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v3/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
@@ -50,14 +59,16 @@ public class RadarrClient : ArrClient, IRadarrClient
List<long> ids = items.Select(item => item.Id).ToList(); List<long> ids = items.Select(item => item.Id).ToList();
Uri uri = new(arrInstance.Url, "/api/v3/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
RadarrCommand command = new() RadarrCommand command = new()
{ {
Name = "MoviesSearch", Name = "MoviesSearch",
MovieIds = ids, MovieIds = ids,
}; };
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command), JsonConvert.SerializeObject(command),
Encoding.UTF8, Encoding.UTF8,
@@ -135,8 +146,10 @@ public class RadarrClient : ArrClient, IRadarrClient
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId) private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/movie/{movieId}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -28,18 +28,27 @@ public class SonarrClient : ArrClient, ISonarrClient
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true"; return "/api/v3/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v3/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
@@ -49,11 +58,12 @@ public class SonarrClient : ArrClient, ISonarrClient
return; return;
} }
Uri uri = new(arrInstance.Url, "/api/v3/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet())) foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
{ {
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8, Encoding.UTF8,
@@ -199,8 +209,11 @@ public class SonarrClient : ArrClient, ISonarrClient
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds) private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/episode?{string.Join('&', episodeIds.Select(x => $"episodeIds={x}"))}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -212,8 +225,10 @@ public class SonarrClient : ArrClient, ISonarrClient
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId) private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/series/{seriesId}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -142,7 +142,7 @@ public sealed class ContentBlocker : GenericHandler
removeFromClient = false; removeFromClient = false;
} }
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient, DeleteReason.AllFilesBlocked);
await _notifier.NotifyQueueItemDeleted(removeFromClient, DeleteReason.AllFilesBlocked); await _notifier.NotifyQueueItemDeleted(removeFromClient, DeleteReason.AllFilesBlocked);
} }
}); });
@@ -33,6 +33,7 @@ public sealed class DelugeClient
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory) public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
{ {
_config = config.Value; _config = config.Value;
_config.Validate();
_httpClient = httpClientFactory.CreateClient(nameof(DelugeService)); _httpClient = httpClientFactory.CreateClient(nameof(DelugeService));
} }
@@ -78,6 +79,8 @@ public sealed class DelugeClient
} }
public async Task<TorrentStatus?> GetTorrentStatus(string hash) public async Task<TorrentStatus?> GetTorrentStatus(string hash)
{
try
{ {
return await SendRequest<TorrentStatus?>( return await SendRequest<TorrentStatus?>(
"web.get_torrent_status", "web.get_torrent_status",
@@ -85,6 +88,17 @@ public sealed class DelugeClient
Fields Fields
); );
} }
catch (DelugeClientException e)
{
// Deluge returns an error when the torrent is not found
if (e.Message == "AttributeError: 'NoneType' object has no attribute 'call'")
{
return null;
}
throw;
}
}
public async Task<List<TorrentStatus>?> GetStatusForAllTorrents() public async Task<List<TorrentStatus>?> GetStatusForAllTorrents()
{ {
@@ -122,7 +136,11 @@ public sealed class DelugeClient
StringContent content = new StringContent(json); StringContent content = new StringContent(json);
content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json"); content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json");
var responseMessage = await _httpClient.PostAsync(new Uri(_config.Url, "/json"), content); UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/json"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/json";
var responseMessage = await _httpClient.PostAsync(uriBuilder.Uri, content);
responseMessage.EnsureSuccessStatusCode(); responseMessage.EnsureSuccessStatusCode();
var responseJson = await responseMessage.Content.ReadAsStringAsync(); var responseJson = await responseMessage.Content.ReadAsStringAsync();
@@ -65,6 +65,8 @@ public class DelugeService : DownloadService, IDelugeService
return result; return result;
} }
result.IsPrivate = download.Private;
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);
@@ -80,6 +82,7 @@ public class DelugeService : DownloadService, IDelugeService
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash); _logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
} }
bool shouldRemove = contents?.Contents?.Count > 0; bool shouldRemove = contents?.Contents?.Count > 0;
ProcessFiles(contents.Contents, (_, file) => ProcessFiles(contents.Contents, (_, file) =>
@@ -92,16 +95,14 @@ public class DelugeService : DownloadService, IDelugeService
if (shouldRemove) if (shouldRemove)
{ {
result.DeleteReason = DeleteReason.AllFilesBlocked; // remove if all files are unwanted
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
} }
result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(download); // remove if download is stuck
result.IsPrivate = download.Private; (result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download);
if (!shouldRemove && result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
@@ -295,33 +296,33 @@ public class DelugeService : DownloadService, IDelugeService
await _client.ChangeFilesPriority(hash, sortedPriorities); await _client.ChangeFilesPriority(hash, sortedPriorities);
} }
private async Task<bool> IsItemStuckAndShouldRemove(TorrentStatus status) private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentStatus status)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
return false; return (false, default);
} }
if (_queueCleanerConfig.StalledIgnorePrivate && status.Private) if (_queueCleanerConfig.StalledIgnorePrivate && status.Private)
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name); _logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
return false; return (false, default);
} }
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, default);
} }
if (status.Eta > 0) if (status.Eta > 0)
{ {
return false; return (false, default);
} }
ResetStrikesOnProgress(status.Hash!, status.TotalDone); ResetStrikesOnProgress(status.Hash!, status.TotalDone);
return await StrikeAndCheckLimit(status.Hash!, status.Name!); return (await StrikeAndCheckLimit(status.Hash!, status.Name!, StrikeType.Stalled), DeleteReason.Stalled);
} }
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile) private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile)
@@ -100,10 +100,11 @@ public abstract class DownloadService : IDownloadService
/// </summary> /// </summary>
/// <param name="hash">The torrent hash.</param> /// <param name="hash">The torrent hash.</param>
/// <param name="itemName">The name or title of the item.</param> /// <param name="itemName">The name or title of the item.</param>
/// <param name="strikeType"></param>
/// <returns>True if the limit has been reached; otherwise, false.</returns> /// <returns>True if the limit has been reached; otherwise, false.</returns>
protected async Task<bool> StrikeAndCheckLimit(string hash, string itemName) protected async Task<bool> StrikeAndCheckLimit(string hash, string itemName, StrikeType strikeType)
{ {
return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled); 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, Category category)
@@ -45,7 +45,11 @@ public class QBitService : DownloadService, IQBitService
{ {
_config = config.Value; _config = config.Value;
_config.Validate(); _config.Validate();
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), _config.Url); UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? uriBuilder.Path
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/')}";
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri);
} }
public override async Task LoginAsync() public override async Task LoginAsync()
@@ -92,31 +96,27 @@ public class QBitService : DownloadService, IQBitService
bool.TryParse(dictValue?.ToString(), out bool boolValue) bool.TryParse(dictValue?.ToString(), out bool boolValue)
&& boolValue; && boolValue;
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
return result;
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash); IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
// 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))
{ {
result.ShouldRemove = true; result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
return result; return result;
} }
result.ShouldRemove = await IsItemStuckAndShouldRemove(download, result.IsPrivate); // remove if all files are unwanted
result.DeleteReason = DeleteReason.AllFilesSkipped;
if (result.ShouldRemove) return result;
{
result.DeleteReason = DeleteReason.Stalled;
} }
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download, result.IsPrivate);
return result; return result;
} }
@@ -333,30 +333,35 @@ public class QBitService : DownloadService, IQBitService
_client.Dispose(); _client.Dispose();
} }
private async Task<bool> IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate) private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
return false; return (false, default);
} }
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate) if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
return false; return (false, default);
} }
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)
{ {
// ignore other states // ignore other states
return false; return (false, default);
} }
ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0); ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
return await StrikeAndCheckLimit(torrent.Hash, torrent.Name); if (torrent.State is TorrentState.StalledDownload)
{
return (await StrikeAndCheckLimit(torrent.Hash, torrent.Name, StrikeType.Stalled), DeleteReason.Stalled);
}
return (await StrikeAndCheckLimit(torrent.Hash, torrent.Name, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
} }
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash) private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
@@ -64,9 +64,13 @@ public class TransmissionService : DownloadService, ITransmissionService
{ {
_config = config.Value; _config = config.Value;
_config.Validate(); _config.Validate();
UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/rpc"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/rpc";
_client = new( _client = new(
httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), httpClientFactory.CreateClient(Constants.HttpClientWithRetryName),
new Uri(_config.Url, "/transmission/rpc").ToString(), uriBuilder.Uri.ToString(),
login: _config.Username, login: _config.Username,
password: _config.Password password: _config.Password
); );
@@ -115,16 +119,14 @@ public class TransmissionService : DownloadService, ITransmissionService
if (shouldRemove) if (shouldRemove)
{ {
// remove if all files are unwanted
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked; result.DeleteReason = DeleteReason.AllFilesBlocked;
return result;
} }
// remove if all files are unwanted or download is stuck // remove if download is stuck
result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(download); (result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download);
if (!shouldRemove && result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
@@ -334,34 +336,34 @@ public class TransmissionService : DownloadService, ITransmissionService
}); });
} }
private async Task<bool> IsItemStuckAndShouldRemove(TorrentInfo torrent) private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentInfo torrent)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
return false; return (false, default);
} }
if (_queueCleanerConfig.StalledIgnorePrivate && (torrent.IsPrivate ?? false)) if (_queueCleanerConfig.StalledIgnorePrivate && (torrent.IsPrivate ?? false))
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
return false; return (false, default);
} }
if (torrent.Status is not 4) if (torrent.Status is not 4)
{ {
// not in downloading state // not in downloading state
return false; return (false, default);
} }
if (torrent.Eta > 0) if (torrent.Eta > 0)
{ {
return false; return (false, default);
} }
ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0); ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0);
return await StrikeAndCheckLimit(torrent.HashString!, torrent.Name!); return (await StrikeAndCheckLimit(torrent.HashString!, torrent.Name!, StrikeType.Stalled), DeleteReason.Stalled);
} }
private async Task<TorrentInfo?> GetTorrentAsync(string hash) private async Task<TorrentInfo?> GetTorrentAsync(string hash)
@@ -132,7 +132,7 @@ public sealed class QueueCleaner : GenericHandler
} }
} }
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient, deleteReason);
await _notifier.NotifyQueueItemDeleted(removeFromClient, deleteReason); await _notifier.NotifyQueueItemDeleted(removeFromClient, deleteReason);
} }
}); });
+18
View File
@@ -378,6 +378,12 @@
- Default: `http://localhost:8080`. - Default: `http://localhost:8080`.
- Required: No. - Required: No.
#### **`QBITTORRENT__URL_BASE`**
- Adds a prefix to the qBittorrent url, such as `[QBITTORRENT__URL]/[QBITTORRENT__URL_BASE]/api`.
- Type: String.
- Default: Empty.
- Required: No.
#### **`QBITTORRENT__USERNAME`** #### **`QBITTORRENT__USERNAME`**
- Username for qBittorrent authentication. - Username for qBittorrent authentication.
- Type: String. - Type: String.
@@ -396,6 +402,12 @@
- Default: `http://localhost:8112`. - Default: `http://localhost:8112`.
- Required: No. - Required: No.
#### **`DELUGE__URL_BASE`**
- Adds a prefix to the deluge json url, such as `[DELUGE__URL]/[DELUGE__URL_BASE]/json`.
- Type: String.
- Default: Empty.
- Required: No.
#### **`DELUGE__PASSWORD`** #### **`DELUGE__PASSWORD`**
- Password for Deluge authentication. - Password for Deluge authentication.
- Type: String. - Type: String.
@@ -408,6 +420,12 @@
- Default: `http://localhost:9091`. - Default: `http://localhost:9091`.
- Required: No. - Required: No.
#### **`TRANSMISSION__URL_BASE`**
- Adds a prefix to the Transmission rpc url, such as `[TRANSMISSION__URL]/[TRANSMISSION__URL_BASE]/rpc`.
- Type: String.
- Default: `transmission`.
- Required: No.
#### **`TRANSMISSION__USERNAME`** #### **`TRANSMISSION__USERNAME`**
- Username for Transmission authentication. - Username for Transmission authentication.
- Type: String. - Type: String.