From 37cb978f182dc3f04a10b45ba682473b1119f16a Mon Sep 17 00:00:00 2001 From: Alexander WB Date: Tue, 2 Sep 2025 00:08:03 +0200 Subject: [PATCH] New: RQBit download client Co-authored-by: Mark Mendoza --- .../RQBitTests/RQBitFixture.cs | 234 ++++++++++++++++++ .../Download/Clients/RQBit/RQBit.cs | 211 ++++++++++++++++ .../Download/Clients/RQBit/RQBitFile.cs | 8 + .../Download/Clients/RQBit/RQBitTorrent.cs | 20 ++ .../Download/Clients/RQBit/RQbitProxy.cs | 234 ++++++++++++++++++ .../Download/Clients/RQBit/RQbitSettings.cs | 48 ++++ .../ListTorrentsWithStatsResponse.cs | 109 ++++++++ .../ResponseModels/PostTorrentResponse.cs | 25 ++ .../RQBit/ResponseModels/RootResponse.cs | 10 + .../ResponseModels/TorrentFileResponse.cs | 11 + .../RQBit/ResponseModels/TorrentResponse.cs | 16 ++ .../RQBit/ResponseModels/TorrentState.cs | 11 + 12 files changed, 937 insertions(+) create mode 100644 src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/ListTorrentsWithStatsResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentState.cs 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 000000000..5003a3ea3 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs @@ -0,0 +1,234 @@ +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; + +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 = CreateRemoteEpisode(); + + 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 = CreateRemoteEpisode(); + 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"); + } + + [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"); + } + } +} 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 000000000..0e7f5d83a --- /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(RemoteEpisode remoteEpisode, string hash, string magnetLink) + { + return _proxy.AddTorrentFromUrl(magnetLink, Settings); + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, 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 000000000..ce35a3f49 --- /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 000000000..b7e49f36b --- /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 000000000..607152568 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -0,0 +1,234 @@ +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 + 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 000000000..afc44e873 --- /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 000000000..cd0f8fbb5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/ListTorrentsWithStatsResponse.cs @@ -0,0 +1,109 @@ +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; } + 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 000000000..60cad2ed7 --- /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 000000000..43f8fe510 --- /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 000000000..59553fb4d --- /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 000000000..5f1a779f4 --- /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 000000000..fb409598c --- /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 +}