diff --git a/.github/workflows/build_auto.yml b/.github/workflows/build_auto.yml
index 3c006b6345b..dee0704c296 100644
--- a/.github/workflows/build_auto.yml
+++ b/.github/workflows/build_auto.yml
@@ -16,7 +16,7 @@ env:
FRAMEWORK: net10.0
RAW_BRANCH_NAME: ${{ inputs.branch || github.head_ref || github.ref_name }}
RADARR_MAJOR_VERSION: 6
- VERSION: ${{ inputs.version || '6.0.4' }}
+ VERSION: ${{ inputs.version || '6.4.2' }}
jobs:
prepare:
diff --git a/.github/workflows/release_auto.yml b/.github/workflows/release_auto.yml
index 0f8784354e1..6080fdd0826 100644
--- a/.github/workflows/release_auto.yml
+++ b/.github/workflows/release_auto.yml
@@ -15,7 +15,7 @@ env:
FRAMEWORK: net10.0
RAW_BRANCH_NAME: main
RADARR_MAJOR_VERSION: 6
- VERSION: '6.0.5'
+ VERSION: '6.4.2'
jobs:
prepare:
diff --git a/src/NzbDrone.Automation.Test/Radarr.Automation.Test.csproj b/src/NzbDrone.Automation.Test/Radarr.Automation.Test.csproj
index 155c9084620..af8b9808654 100644
--- a/src/NzbDrone.Automation.Test/Radarr.Automation.Test.csproj
+++ b/src/NzbDrone.Automation.Test/Radarr.Automation.Test.csproj
@@ -4,7 +4,7 @@
-
+
diff --git a/src/NzbDrone.Common/Radarr.Common.csproj b/src/NzbDrone.Common/Radarr.Common.csproj
index 9c091bf80a1..6f56087c88c 100644
--- a/src/NzbDrone.Common/Radarr.Common.csproj
+++ b/src/NzbDrone.Common/Radarr.Common.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs
new file mode 100644
index 00000000000..2df1da2d67a
--- /dev/null
+++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs
@@ -0,0 +1,237 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Download.Clients.RQBit;
+using NzbDrone.Core.MediaFiles.TorrentInfo;
+using NzbDrone.Test.Common;
+
+namespace NzbDrone.Core.Test.Download.DownloadClientTests.RQBitTests
+{
+ [TestFixture]
+ public class RQBitFixture : DownloadClientFixtureBase
+ {
+ protected RQBitTorrent _queued;
+ protected RQBitTorrent _downloading;
+ protected RQBitTorrent _failed;
+ protected RQBitTorrent _completed;
+
+ [SetUp]
+ public void Setup()
+ {
+ Subject.Definition = new DownloadClientDefinition();
+ Subject.Definition.Settings = new RQbitSettings
+ {
+ Host = "127.0.0.1",
+ Port = 3030,
+ UseSsl = false
+ };
+
+ _queued = new RQBitTorrent
+ {
+ Hash = "HASH",
+ IsFinished = false,
+ IsActive = false,
+ Name = _title,
+ TotalSize = 1000,
+ RemainingSize = 1000,
+ Path = "somepath"
+ };
+
+ _downloading = new RQBitTorrent
+ {
+ Hash = "HASH",
+ IsFinished = false,
+ IsActive = true,
+ Name = _title,
+ TotalSize = 1000,
+ RemainingSize = 100,
+ Path = "somepath"
+ };
+
+ _failed = new RQBitTorrent
+ {
+ Hash = "HASH",
+ IsFinished = false,
+ IsActive = false,
+ Name = _title,
+ TotalSize = 1000,
+ RemainingSize = 1000,
+ Path = "somepath"
+ };
+
+ _completed = new RQBitTorrent
+ {
+ Hash = "HASH",
+ IsFinished = true,
+ IsActive = false,
+ Name = _title,
+ TotalSize = 1000,
+ RemainingSize = 0,
+ Path = "somepath"
+ };
+
+ Mocker.GetMock()
+ .Setup(s => s.GetHashFromTorrentFile(It.IsAny()))
+ .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
+ }
+
+ protected void GivenSuccessfulDownload()
+ {
+ Mocker.GetMock()
+ .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny()))
+ .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
+ Mocker.GetMock()
+ .Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
+ }
+
+ protected virtual void GivenTorrents(List torrents)
+ {
+ if (torrents == null)
+ {
+ torrents = new List();
+ }
+
+ Mocker.GetMock()
+ .Setup(s => s.GetTorrents(It.IsAny()))
+ .Returns(torrents);
+ }
+
+ protected void PrepareClientToReturnQueuedItem()
+ {
+ GivenTorrents(new List
+ {
+ _queued
+ });
+ }
+
+ protected void PrepareClientToReturnDownloadingItem()
+ {
+ GivenTorrents(new List
+ {
+ _downloading
+ });
+ }
+
+ protected void PrepareClientToReturnFailedItem()
+ {
+ GivenTorrents(new List
+ {
+ _failed
+ });
+ }
+
+ protected void PrepareClientToReturnCompletedItem()
+ {
+ GivenTorrents(new List
+ {
+ _completed
+ });
+ }
+
+ [Test]
+ public void queued_item_should_have_required_properties()
+ {
+ PrepareClientToReturnQueuedItem();
+ var item = Subject.GetItems().Single();
+ VerifyPaused(item);
+ }
+
+ [Test]
+ public void downloading_item_should_have_required_properties()
+ {
+ PrepareClientToReturnDownloadingItem();
+ var item = Subject.GetItems().Single();
+ VerifyDownloading(item);
+ }
+
+ [Test]
+ public void failed_item_should_have_required_properties()
+ {
+ PrepareClientToReturnFailedItem();
+ var item = Subject.GetItems().Single();
+ VerifyPaused(item);
+ }
+
+ [Test]
+ public void completed_download_should_have_required_properties()
+ {
+ PrepareClientToReturnCompletedItem();
+ var item = Subject.GetItems().Single();
+ VerifyCompleted(item);
+ }
+
+ [Test]
+ public async Task Download_should_return_unique_id()
+ {
+ GivenSuccessfulDownload();
+
+ var remoteEpisode = CreateRemoteMovie();
+
+ var id = await Subject.Download(remoteEpisode, CreateIndexer());
+
+ id.Should().NotBeNullOrEmpty();
+ }
+
+ [TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")]
+ public async Task Download_should_get_hash_from_magnet_url(string magnetUrl, string expectedHash)
+ {
+ GivenSuccessfulDownload();
+
+ var remoteEpisode = CreateRemoteMovie();
+ remoteEpisode.Release.DownloadUrl = magnetUrl;
+
+ var id = await Subject.Download(remoteEpisode, CreateIndexer());
+
+ id.Should().Be(expectedHash);
+ }
+
+ [Test]
+ public void should_return_status_with_outputdirs()
+ {
+ var result = Subject.GetStatus();
+
+ result.IsLocalhost.Should().BeTrue();
+ }
+
+ [Test]
+ public void GetItems_should_ignore_torrents_with_empty_path()
+ {
+ var torrents = new List
+ {
+ new RQBitTorrent { Name = "Test1", Hash = "Hash1", Path = "" },
+ new RQBitTorrent { Name = "Test2", Hash = "Hash2", Path = "/valid/path" }
+ };
+
+ GivenTorrents(torrents);
+
+ var items = Subject.GetItems();
+
+ items.Should().HaveCount(1);
+ items.First().Title.Should().Be("Test2");
+ ExceptionVerification.ExpectedWarns(1);
+ }
+
+ [Test]
+ public void GetItems_should_ignore_torrents_with_relative_path()
+ {
+ var torrents = new List
+ {
+ new RQBitTorrent { Name = "Test1", Hash = "Hash1", Path = "./relative/path" },
+ new RQBitTorrent { Name = "Test2", Hash = "Hash2", Path = "/absolute/path" }
+ };
+
+ GivenTorrents(torrents);
+
+ var items = Subject.GetItems();
+
+ items.Should().HaveCount(1);
+ items.First().Title.Should().Be("Test2");
+ ExceptionVerification.ExpectedWarns(1);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs
new file mode 100644
index 00000000000..da720790f4c
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using FluentValidation.Results;
+using NLog;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.Blocklisting;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Localization;
+using NzbDrone.Core.MediaFiles.TorrentInfo;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.RemotePathMappings;
+using NzbDrone.Core.Validation;
+
+namespace NzbDrone.Core.Download.Clients.RQBit
+{
+ public class RQBit : TorrentClientBase
+ {
+ private readonly IRQbitProxy _proxy;
+ private readonly IDownloadSeedConfigProvider _downloadSeedConfigProvider;
+
+ public override string Name => "RQBit";
+
+ public RQBit(IRQbitProxy proxy,
+ ITorrentFileInfoReader torrentFileInfoReader,
+ IHttpClient httpClient,
+ IConfigService configService,
+ IDiskProvider diskProvider,
+ IRemotePathMappingService remotePathMappingService,
+ IDownloadSeedConfigProvider downloadSeedConfigProvider,
+ ILocalizationService localizationService,
+ IBlocklistService blocklistService,
+ Logger logger)
+ : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, localizationService, blocklistService, logger)
+ {
+ _proxy = proxy;
+ _downloadSeedConfigProvider = downloadSeedConfigProvider;
+ }
+
+ public override IEnumerable GetItems()
+ {
+ var torrents = _proxy.GetTorrents(Settings);
+
+ _logger.Debug("Retrieved metadata of {0} torrents in client", torrents.Count);
+
+ var items = new List();
+ foreach (var torrent in torrents)
+ {
+ // Ignore torrents with an empty path
+ if (torrent.Path.IsNullOrWhiteSpace())
+ {
+ _logger.Warn("Torrent '{0}' has an empty download path and will not be processed", torrent.Name);
+ continue;
+ }
+
+ if (torrent.Path.StartsWith("."))
+ {
+ _logger.Warn("Torrent '{0}' has a download path starting with '.' and will not be processed", torrent.Name);
+ continue;
+ }
+
+ var item = new DownloadClientItem();
+ item.DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false);
+ item.Title = torrent.Name;
+ item.DownloadId = torrent.Hash;
+ item.Message = torrent.Message;
+
+ item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.Path));
+ item.TotalSize = torrent.TotalSize;
+ item.RemainingSize = torrent.RemainingSize;
+ item.Category = torrent.Category;
+ item.SeedRatio = torrent.Ratio;
+
+ if (torrent.DownRate > 0)
+ {
+ var secondsLeft = torrent.RemainingSize / torrent.DownRate;
+ item.RemainingTime = TimeSpan.FromSeconds(secondsLeft);
+ }
+ else
+ {
+ item.RemainingTime = TimeSpan.Zero;
+ }
+
+ if (torrent.IsFinished)
+ {
+ item.Status = DownloadItemStatus.Completed;
+ }
+ else if (torrent.IsActive)
+ {
+ item.Status = DownloadItemStatus.Downloading;
+ }
+ else if (!torrent.IsActive)
+ {
+ item.Status = DownloadItemStatus.Paused;
+ }
+
+ // Grab cached seedConfig
+ var seedConfig = _downloadSeedConfigProvider.GetSeedConfiguration(torrent.Hash);
+
+ if (item.DownloadClientInfo.RemoveCompletedDownloads && torrent.IsFinished && seedConfig != null)
+ {
+ var canRemove = false;
+
+ if (torrent.Ratio / 1000.0 >= seedConfig.Ratio)
+ {
+ _logger.Trace($"{item} has met seed ratio goal of {seedConfig.Ratio}");
+ canRemove = true;
+ }
+ else if (DateTimeOffset.Now - DateTimeOffset.FromUnixTimeSeconds(torrent.FinishedTime) >= seedConfig.SeedTime)
+ {
+ _logger.Trace($"{item} has met seed time goal of {seedConfig.SeedTime} minutes");
+ canRemove = true;
+ }
+ else
+ {
+ _logger.Trace($"{item} seeding goals have not yet been reached");
+ }
+
+ // Check if torrent is finished and if it exceeds cached seedConfig
+ item.CanMoveFiles = item.CanBeRemoved = canRemove;
+ }
+
+ items.Add(item);
+ }
+
+ return items;
+ }
+
+ public override void RemoveItem(DownloadClientItem item, bool deleteData)
+ {
+ _proxy.RemoveTorrent(item.DownloadId, deleteData, Settings);
+ }
+
+ public override DownloadClientInfo GetStatus()
+ {
+ return new DownloadClientInfo
+ {
+ IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost",
+ };
+ }
+
+ protected override void Test(List failures)
+ {
+ failures.AddIfNotNull(TestConnection());
+
+ if (failures.HasErrors())
+ {
+ return;
+ }
+
+ failures.AddIfNotNull(TestVersion());
+ }
+
+ private ValidationFailure TestConnection()
+ {
+ var version = _proxy.GetVersion(Settings);
+ return null;
+ }
+
+ private ValidationFailure TestVersion()
+ {
+ try
+ {
+ var versionString = _proxy.GetVersion(Settings);
+
+ if (string.IsNullOrWhiteSpace(versionString))
+ {
+ return new ValidationFailure("", "Unable to determine RQBit version. Please check that RQBit is running and accessible.");
+ }
+
+ // Parse version string to Version object
+ var versionMatch = System.Text.RegularExpressions.Regex.Match(versionString, @"(\d+)\.(\d+)\.(\d+)");
+
+ if (!versionMatch.Success)
+ {
+ return new ValidationFailure("", $"Unable to parse RQBit version '{versionString}'. Please check that RQBit is running and accessible.");
+ }
+
+ var major = int.Parse(versionMatch.Groups[1].Value);
+ var minor = int.Parse(versionMatch.Groups[2].Value);
+ var patch = int.Parse(versionMatch.Groups[3].Value);
+ var apiVersion = new Version(major, minor, patch);
+
+ var minimumVersion = new Version(8, 0, 0);
+
+ if (apiVersion < minimumVersion)
+ {
+ return new ValidationFailure("", $"RQBit version {apiVersion} is not supported. Please upgrade to version {minimumVersion} or higher.");
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to determine RQBit version");
+ return new ValidationFailure("", "Unable to determine RQBit version. Please check that RQBit is running and accessible.");
+ }
+ }
+
+ protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink)
+ {
+ return _proxy.AddTorrentFromUrl(magnetLink, Settings);
+ }
+
+ protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string hash, string filename, byte[] fileContent)
+ {
+ return _proxy.AddTorrentFromFile(filename, fileContent, Settings);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs
new file mode 100644
index 00000000000..ce35a3f4912
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs
@@ -0,0 +1,8 @@
+namespace NzbDrone.Core.Download.Clients.RQBit;
+
+public class RQBitFile
+{
+ public string FileName { get; set; }
+ public int FileSize { get; set; }
+ public int FileDownloaded { get; set; }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs
new file mode 100644
index 00000000000..b7e49f36b64
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs
@@ -0,0 +1,20 @@
+namespace NzbDrone.Core.Download.Clients.RQBit
+{
+ public class RQBitTorrent
+ {
+ public long Id { get; set; }
+ public string Name { get; set; }
+ public string Hash { get; set; }
+ public long TotalSize { get; set; }
+ public long RemainingSize { get; set; }
+ public string Category { get; set; }
+ public double? Ratio { get; set; }
+ public long DownRate { get; set; }
+ public bool IsFinished { get; set; }
+ public bool IsActive { get; set; }
+ public bool HasError { get; set; }
+ public long FinishedTime { get; set; }
+ public string Path { get; set; }
+ public string Message { get; set; }
+ }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs
new file mode 100644
index 00000000000..4a1612ca829
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using Newtonsoft.Json;
+using NLog;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.Download.Clients.RQBit.ResponseModels;
+
+namespace NzbDrone.Core.Download.Clients.RQBit
+{
+ public interface IRQbitProxy
+ {
+ bool IsApiSupported(RQbitSettings settings);
+ string GetVersion(RQbitSettings settings);
+ List GetTorrents(RQbitSettings settings);
+ void RemoveTorrent(string hash, bool removeData, RQbitSettings settings);
+ string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings);
+ string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings);
+ void SetTorrentLabel(string hash, string label, RQbitSettings settings);
+ bool HasHashTorrent(string hash, RQbitSettings settings);
+ }
+
+ public class RQbitProxy : IRQbitProxy
+ {
+ private readonly IHttpClient _httpClient;
+ private readonly Logger _logger;
+
+ public RQbitProxy(IHttpClient httpClient, Logger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public bool IsApiSupported(RQbitSettings settings)
+ {
+ var request = BuildRequest(settings).Resource("");
+ request.SuppressHttpError = true;
+
+ try
+ {
+ var response = _httpClient.Get(request.Build());
+
+ // Check if we can connect and get a valid response
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+ var rootResponse = JsonConvert.DeserializeObject(response.Content);
+ return rootResponse?.Version.IsNotNullOrWhiteSpace() ?? false;
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug(ex, "RQBit API not supported or not reachable");
+ return false;
+ }
+ }
+
+ public string GetVersion(RQbitSettings settings)
+ {
+ var request = BuildRequest(settings).Resource("");
+ var response = _httpClient.Get(request.Build());
+
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+ var rootResponse = JsonConvert.DeserializeObject(response.Content);
+ return rootResponse.Version;
+ }
+ else
+ {
+ _logger.Error("Failed to get torrent version");
+ }
+
+ return string.Empty;
+ }
+
+ public List GetTorrents(RQbitSettings settings)
+ {
+ var result = new List();
+ var request = BuildRequest(settings).Resource("/torrents?with_stats=true");
+ var response = _httpClient.Get(request.Build());
+
+ if (response.StatusCode != HttpStatusCode.OK)
+ {
+ _logger.Error("Failed to get torrent list with stats");
+ return result;
+ }
+
+ var torrentListWithStats = JsonConvert.DeserializeObject(response.Content);
+
+ if (torrentListWithStats?.Torrents != null)
+ {
+ foreach (var torrentWithStats in torrentListWithStats.Torrents)
+ {
+ try
+ {
+ var torrent = new RQBitTorrent();
+ torrent.Id = torrentWithStats.Id;
+ torrent.Name = torrentWithStats.Name;
+ torrent.Hash = torrentWithStats.InfoHash;
+ torrent.TotalSize = torrentWithStats.Stats.TotalBytes;
+ torrent.Path = torrentWithStats.OutputFolder;
+
+ if (!torrentWithStats.OutputFolder.EndsWith(torrentWithStats.Name))
+ {
+ torrent.Path += torrentWithStats.Name;
+ }
+
+ var statsLive = torrentWithStats.Stats.Live;
+ if (statsLive?.DownloadSpeed != null)
+ {
+ // Convert mib/sec to bytes per second
+ torrent.DownRate = (long)(statsLive.DownloadSpeed.Mbps * 1048576);
+ }
+
+ torrent.RemainingSize = torrentWithStats.Stats.TotalBytes - torrentWithStats.Stats.ProgressBytes;
+
+ // Avoid division by zero
+ if (torrentWithStats.Stats.ProgressBytes > 0)
+ {
+ torrent.Ratio = (double)torrentWithStats.Stats.UploadedBytes / torrentWithStats.Stats.ProgressBytes;
+ }
+ else
+ {
+ torrent.Ratio = 0;
+ }
+
+ torrent.IsFinished = torrentWithStats.Stats.Finished;
+ torrent.IsActive = torrentWithStats.Stats.State == TorrentState.Live || torrentWithStats.Stats.State == TorrentState.Initializing;
+ torrent.HasError = torrentWithStats.Stats.State == TorrentState.Error || torrentWithStats.Stats.State == TorrentState.Invalid;
+
+ torrent.Message = torrentWithStats.Stats.Error;
+
+ result.Add(torrent);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to process torrent {0}", torrentWithStats.InfoHash);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public void RemoveTorrent(string infoHash, bool removeData, RQbitSettings settings)
+ {
+ var endpoint = removeData ? "/delete" : "/forget";
+ var itemRequest = BuildRequest(settings).Resource("/torrents/" + infoHash + endpoint);
+ _httpClient.Post(itemRequest.Build());
+ }
+
+ public string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings)
+ {
+ var itemRequest = BuildRequest(settings).Resource("/torrents?overwrite=true").Post().Build();
+ itemRequest.SetContent(torrentUrl);
+ var httpResponse = _httpClient.Post(itemRequest);
+
+ if (httpResponse.StatusCode != HttpStatusCode.OK)
+ {
+ return null;
+ }
+
+ var response = JsonConvert.DeserializeObject(httpResponse.Content);
+
+ if (response.Details == null)
+ {
+ return null;
+ }
+
+ return response.Details.InfoHash;
+ }
+
+ public string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings)
+ {
+ var itemRequest = BuildRequest(settings)
+ .Post()
+ .Resource("/torrents?overwrite=true")
+ .Build();
+ itemRequest.SetContent(fileContent);
+ var httpResponse = _httpClient.Post(itemRequest);
+
+ if (httpResponse.StatusCode != HttpStatusCode.OK)
+ {
+ return null;
+ }
+
+ var response = JsonConvert.DeserializeObject(httpResponse.Content);
+
+ if (response.Details == null)
+ {
+ return null;
+ }
+
+ return response.Details.InfoHash;
+ }
+
+ public void SetTorrentLabel(string hash, string label, RQbitSettings settings)
+ {
+ _logger.Warn("Torrent labels currently unsupported by RQBit");
+ }
+
+ public bool HasHashTorrent(string hash, RQbitSettings settings)
+ {
+ var result = true;
+ var rqBitTorrentResponse = GetTorrent(hash, settings);
+ if (rqBitTorrentResponse == null || rqBitTorrentResponse.InfoHash.IsNullOrWhiteSpace())
+ {
+ result = false;
+ }
+
+ return result;
+ }
+
+ private TorrentResponse GetTorrent(string infoHash, RQbitSettings settings)
+ {
+ var itemRequest = BuildRequest(settings).Resource("/torrents/" + infoHash);
+ var itemResponse = _httpClient.Get(itemRequest.Build());
+
+ if (itemResponse.StatusCode != HttpStatusCode.OK)
+ {
+ return null;
+ }
+
+ return JsonConvert.DeserializeObject(itemResponse.Content);
+ }
+
+ private HttpRequestBuilder BuildRequest(RQbitSettings settings)
+ {
+ var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase)
+ {
+ LogResponseContent = true,
+ };
+
+ return requestBuilder;
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs
new file mode 100644
index 00000000000..afc44e873ca
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs
@@ -0,0 +1,48 @@
+using FluentValidation;
+using NzbDrone.Core.Annotations;
+using NzbDrone.Core.Validation;
+
+namespace NzbDrone.Core.Download.Clients.RQBit
+{
+ public class RQbitSettings : DownloadClientSettingsBase
+ {
+ private static readonly RQbitSettingsValidator Validator = new();
+
+ public RQbitSettings()
+ {
+ Host = "localhost";
+ Port = 3030;
+ UrlBase = "/";
+ }
+
+ [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)]
+ public string Host { get; set; }
+
+ [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)]
+ public int Port { get; set; }
+
+ [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox)]
+ [FieldToken(TokenField.HelpText, "DownloadClientRQbitSettingsUseSslHelpText")]
+ public bool UseSsl { get; set; }
+
+ [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true)]
+ [FieldToken(TokenField.HelpText, "DownloadClientRQbitSettingsUrlBaseHelpText")]
+ public string UrlBase { get; set; }
+
+ public override NzbDroneValidationResult Validate()
+ {
+ return new NzbDroneValidationResult(Validator.Validate(this));
+ }
+ }
+
+ public class RQbitSettingsValidator : AbstractValidator
+ {
+ public RQbitSettingsValidator()
+ {
+ RuleFor(c => c.Host).ValidHost();
+ RuleFor(c => c.Port).InclusiveBetween(1, 65535);
+
+ RuleFor(c => c.UrlBase).ValidUrlBase();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/ListTorrentsWithStatsResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/ListTorrentsWithStatsResponse.cs
new file mode 100644
index 00000000000..a7820f3ef44
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/ListTorrentsWithStatsResponse.cs
@@ -0,0 +1,112 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels
+{
+ public class ListTorrentsWithStatsResponse
+ {
+ public List Torrents { get; set; }
+ }
+
+ public class TorrentWithStatsResponse
+ {
+ public long Id { get; set; }
+
+ [JsonProperty("info_hash")]
+ public string InfoHash { get; set; }
+
+ public string Name { get; set; }
+
+ [JsonProperty("output_folder")]
+ public string OutputFolder { get; set; }
+
+ public TorrentStatsResponse Stats { get; set; }
+ }
+
+ public class TorrentStatsResponse
+ {
+ public TorrentState State { get; set; }
+
+ [JsonProperty("file_progress")]
+ public List FileProgress { get; set; }
+
+ public string Error { get; set; }
+
+ [JsonProperty("progress_bytes")]
+ public long ProgressBytes { get; set; }
+
+ [JsonProperty("uploaded_bytes")]
+ public long UploadedBytes { get; set; }
+
+ [JsonProperty("total_bytes")]
+ public long TotalBytes { get; set; }
+
+ public bool Finished { get; set; }
+ public TorrentLiveStatsResponse Live { get; set; }
+ }
+
+ public class TorrentLiveStatsResponse
+ {
+ public TorrentSnapshotResponse Snapshot { get; set; }
+
+ [JsonProperty("download_speed")]
+ public TorrentSpeedResponse DownloadSpeed { get; set; }
+
+ [JsonProperty("upload_speed")]
+ public TorrentSpeedResponse UploadSpeed { get; set; }
+
+ [JsonProperty("time_remaining")]
+ public TorrentTimeRemainingResponse TimeRemaining { get; set; }
+ }
+
+ public class TorrentSnapshotResponse
+ {
+ [JsonProperty("downloaded_and_checked_bytes")]
+ public long DownloadedAndCheckedBytes { get; set; }
+
+ [JsonProperty("fetched_bytes")]
+ public long FetchedBytes { get; set; }
+
+ [JsonProperty("uploaded_bytes")]
+ public long UploadedBytes { get; set; }
+
+ [JsonProperty("peer_stats")]
+ public TorrentPeerStatsResponse PeerStats { get; set; }
+ }
+
+ public class TorrentSpeedResponse
+ {
+ public double Mbps { get; set; }
+
+ [JsonProperty("human_readable")]
+ public string HumanReadable { get; set; }
+ }
+
+ public class TorrentTimeRemainingResponse
+ {
+ public TorrentDurationResponse Duration { get; set; }
+
+ [JsonProperty("human_readable")]
+ public string HumanReadable { get; set; }
+ }
+
+ public class TorrentDurationResponse
+ {
+ public long Secs { get; set; }
+ public long Nanos { get; set; }
+ }
+
+ public class TorrentPeerStatsResponse
+ {
+ public int Queued { get; set; }
+ public int Connecting { get; set; }
+ public int Live { get; set; }
+ public int Seen { get; set; }
+ public int Dead { get; set; }
+
+ [JsonProperty("not_needed")]
+ public int NotNeeded { get; set; }
+
+ public int Steals { get; set; }
+ }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs
new file mode 100644
index 00000000000..60cad2ed791
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels;
+
+public class PostTorrentResponse
+{
+ public long Id { get; set; }
+ public PostTorrentDetailsResponse Details { get; set; }
+
+ [JsonProperty("output_folder")]
+ public string OutputFolder { get; set; }
+
+ [JsonProperty("seen_peers")]
+ public List SeenPeers { get; set; }
+}
+
+public class PostTorrentDetailsResponse
+{
+ [JsonProperty("info_hash")]
+ public string InfoHash { get; set; }
+
+ public string Name { get; set; }
+ public List Files { get; set; }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs
new file mode 100644
index 00000000000..43f8fe510c6
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+
+namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels;
+
+public class RootResponse
+{
+ public Dictionary Apis { get; set; }
+ public string Server { get; set; }
+ public string Version { get; set; }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs
new file mode 100644
index 00000000000..59553fb4db2
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+
+namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels;
+
+public class TorrentFileResponse
+{
+ public string Name { get; set; }
+ public List Components { get; set; }
+ public long Length { get; set; }
+ public bool Included { get; set; }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs
new file mode 100644
index 00000000000..5f1a779f463
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels;
+
+public class TorrentResponse
+{
+ [JsonProperty("info_hash")]
+ public string InfoHash { get; set; }
+
+ public string Name { get; set; }
+ public List Files { get; set; }
+
+ [JsonProperty("output_folder")]
+ public string OutputFolder { get; set; }
+}
diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentState.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentState.cs
new file mode 100644
index 00000000000..fb409598c1f
--- /dev/null
+++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentState.cs
@@ -0,0 +1,11 @@
+namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels;
+
+// https://github.com/ikatson/rqbit/blob/946ad3625892f4f40dde3d0e6bbc3030f68a973c/crates/librqbit/src/torrent_state/mod.rs#L65
+public enum TorrentState
+{
+ Initializing = 0,
+ Paused = 1,
+ Live = 2,
+ Error = 3,
+ Invalid = 4
+}