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 0000000000..93f09e88ef --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs @@ -0,0 +1,241 @@ +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() + { + var mockProxy = new Mock(); + mockProxy.Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + mockProxy.Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(mockProxy.Object); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + var mockProxy = new Mock(); + mockProxy.Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(mockProxy.Object); + } + + 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 remoteMovie = CreateRemoteMovie(); + + var id = await Subject.Download(remoteMovie, 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 remoteMovie = CreateRemoteMovie(); + remoteMovie.Release.DownloadUrl = magnetUrl; + + var id = await Subject.Download(remoteMovie, 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 0000000000..1851170eea --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs @@ -0,0 +1,192 @@ +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 IRQbitProxySelector _proxySelector; + private readonly IDownloadSeedConfigProvider _downloadSeedConfigProvider; + + public RQBit(IRQbitProxySelector proxySelector, + 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) + { + _proxySelector = proxySelector; + _downloadSeedConfigProvider = downloadSeedConfigProvider; + } + + private IRQbitProxy _proxy => _proxySelector.GetProxy(Settings); + + 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.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 apiVersion = _proxySelector.GetApiVersion(Settings); + 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); + } + + public override string Name => "RQBit"; + } +} 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 0000000000..f70cd384a7 --- /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 0000000000..8b7b82ee5b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs @@ -0,0 +1,18 @@ +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 long FinishedTime { get; set; } + public string Path { 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 0000000000..97e91d0e9f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Net; +using Newtonsoft.Json; +using NLog; +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 !string.IsNullOrWhiteSpace(rootResponse?.Version); + } + + return false; + } + catch (Exception ex) + { + _logger.Debug(ex, "RQBit API not supported or not reachable"); + return false; + } + } + + public string GetVersion(RQbitSettings settings) + { + var version = ""; + var request = BuildRequest(settings).Resource(""); + var response = _httpClient.Get(request.Build()); + if (response.StatusCode == HttpStatusCode.OK) + { + var rootResponse = JsonConvert.DeserializeObject(response.Content); + version = rootResponse.Version; + } + else + { + _logger.Error("Failed to get torrent version"); + } + + return version; + } + + 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 != "paused"; + + 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) + { + string infoHash = null; + var itemRequest = BuildRequest(settings).Resource("/torrents?overwrite=true").Post().Build(); + itemRequest.SetContent(torrentUrl); + var httpResponse = _httpClient.Post(itemRequest); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return infoHash; + } + + var response = JsonConvert.DeserializeObject(httpResponse.Content); + + if (response.Details != null) + { + infoHash = response.Details.InfoHash; + } + + return infoHash; + } + + public string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings) + { + string infoHash = null; + var itemRequest = BuildRequest(settings) + .Post() + .Resource("/torrents?overwrite=true") + .Build(); + itemRequest.SetContent(fileContent); + var httpResponse = _httpClient.Post(itemRequest); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return infoHash; + } + + var response = JsonConvert.DeserializeObject(httpResponse.Content); + + if (response.Details != null) + { + infoHash = response.Details.InfoHash; + } + + return 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 || string.IsNullOrWhiteSpace(rqBitTorrentResponse.InfoHash)) + { + result = false; + } + + return result; + } + + private TorrentResponse GetTorrent(string infoHash, RQbitSettings settings) + { + TorrentResponse result = null; + var itemRequest = BuildRequest(settings).Resource("/torrents/" + infoHash); + var itemResponse = _httpClient.Get(itemRequest.Build()); + if (itemResponse.StatusCode != HttpStatusCode.OK) + { + return result; + } + + result = JsonConvert.DeserializeObject(itemResponse.Content); + + return result; + } + + 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/RQbitProxySelector.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs new file mode 100644 index 0000000000..34d6f395c3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs @@ -0,0 +1,112 @@ +using System; +using NLog; +using NzbDrone.Common.Cache; + +namespace NzbDrone.Core.Download.Clients.RQBit +{ + public interface IRQbitProxySelector + { + IRQbitProxy GetProxy(RQbitSettings settings, bool force = false); + Version GetApiVersion(RQbitSettings settings, bool force = false); + } + + public class RQbitProxySelector : IRQbitProxySelector + { + private readonly ICached> _proxyCache; + private readonly Logger _logger; + private readonly IRQbitProxy _proxyV1; + + public RQbitProxySelector(RQbitProxy proxyV1, ICacheManager cacheManager, Logger logger) + { + _proxyCache = cacheManager.GetCache>(GetType()); + _logger = logger; + _proxyV1 = proxyV1; + } + + public IRQbitProxy GetProxy(RQbitSettings settings, bool force) + { + return GetProxyCache(settings, force).Item1; + } + + public Version GetApiVersion(RQbitSettings settings, bool force) + { + return GetProxyCache(settings, force).Item2; + } + + private Tuple GetProxyCache(RQbitSettings settings, bool force) + { + var proxyKey = $"{settings.Host}_{settings.Port}"; + + if (force) + { + _proxyCache.Remove(proxyKey); + } + + return _proxyCache.Get(proxyKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private Tuple FetchProxy(RQbitSettings settings) + { + // For now, we only have one API version, but this pattern allows for future extensions + if (_proxyV1.IsApiSupported(settings)) + { + var version = ParseVersion(_proxyV1.GetVersion(settings)); + _logger.Trace("Using RQBit API v1, detected version: {0}", version); + return Tuple.Create(_proxyV1, version); + } + + throw new DownloadClientException("Unable to determine RQBit API version or RQBit is not responding"); + } + + private Version ParseVersion(string versionString) + { + // RQBit version might be in different formats, try to parse it safely + if (string.IsNullOrWhiteSpace(versionString)) + { + return new Version(1, 0, 0); + } + + try + { + // Remove any non-numeric prefix/suffix and try to parse as version + var cleanVersion = versionString.Trim().TrimStart('v'); + + // Handle semantic versioning (e.g., "8.1.0", "8.0.0-rc1") + // Split on '-' to remove pre-release identifiers + var versionPart = cleanVersion.Split('-')[0]; + + // If it's just numbers with dots, parse as version + if (Version.TryParse(versionPart, out var version)) + { + return version; + } + + // Try to extract just the major.minor.patch numbers using regex + var match = System.Text.RegularExpressions.Regex.Match(cleanVersion, @"(\d+)\.(\d+)\.(\d+)"); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + var patch = int.Parse(match.Groups[3].Value); + return new Version(major, minor, patch); + } + + // Try major.minor format + var simpleMatch = System.Text.RegularExpressions.Regex.Match(cleanVersion, @"(\d+)\.(\d+)"); + if (simpleMatch.Success) + { + var major = int.Parse(simpleMatch.Groups[1].Value); + var minor = int.Parse(simpleMatch.Groups[2].Value); + return new Version(major, minor, 0); + } + + // If parsing fails, default to 1.0.0 + return new Version(1, 0, 0); + } + catch + { + return new Version(1, 0, 0); + } + } + } +} 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 0000000000..fe8c3fe527 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs @@ -0,0 +1,36 @@ +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)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs new file mode 100644 index 0000000000..059a7652b6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RQBit; + +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/PostTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs new file mode 100644 index 0000000000..debd6a2a78 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class PostTorrentResponse +{ + [JsonProperty("id")] + public long Id { get; set; } + [JsonProperty("details")] + 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; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("files")] + 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 0000000000..a954ea04e1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class RootResponse +{ + [JsonProperty("apis")] + public Dictionary Apis { get; set; } + [JsonProperty("server")] + public string Server { get; set; } + [JsonProperty("version")] + 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 0000000000..05c19c7617 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentFileResponse +{ + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("components")] + public List Components { get; set; } + [JsonProperty("length")] + public long Length { get; set; } + [JsonProperty("included")] + public bool Included { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs new file mode 100644 index 0000000000..0efd985ab2 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentListResponse +{ + [JsonProperty("torrents")] + public List torrents { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs new file mode 100644 index 0000000000..0675a78ddc --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentListingResponse +{ + [JsonProperty("id")] + public long Id { get; set; } + [JsonProperty("info_hash")] + public string InfoHash { 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 0000000000..f15df682bc --- /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; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("files")] + public List Files { get; set; } + [JsonProperty("output_folder")] + public string OutputFolder { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs new file mode 100644 index 0000000000..c98571cea1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.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 TorrentStatus +{ + Initializing = 0, + Paused = 1, + Live = 2, + Error = 3, + Invalid = 4 +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs new file mode 100644 index 0000000000..93878f65c9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels +{ + public class TorrentWithStatsListResponse + { + [JsonProperty("torrents")] + public List Torrents { get; set; } + } + + public class TorrentWithStatsResponse + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("info_hash")] + public string InfoHash { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("output_folder")] + public string OutputFolder { get; set; } + + [JsonProperty("stats")] + public TorrentStatsResponse Stats { get; set; } + } + + public class TorrentStatsResponse + { + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("file_progress")] + public List FileProgress { get; set; } + + [JsonProperty("error")] + 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; } + + [JsonProperty("finished")] + public bool Finished { get; set; } + + [JsonProperty("live")] + public TorrentLiveStatsResponse Live { get; set; } + } + + public class TorrentLiveStatsResponse + { + [JsonProperty("snapshot")] + 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 + { + [JsonProperty("mbps")] + public double Mbps { get; set; } + + [JsonProperty("human_readable")] + public string HumanReadable { get; set; } + } + + public class TorrentTimeRemainingResponse + { + [JsonProperty("duration")] + public TorrentDurationResponse Duration { get; set; } + + [JsonProperty("human_readable")] + public string HumanReadable { get; set; } + } + + public class TorrentDurationResponse + { + [JsonProperty("secs")] + public long Secs { get; set; } + + [JsonProperty("nanos")] + public long Nanos { get; set; } + } + + public class TorrentPeerStatsResponse + { + [JsonProperty("queued")] + public int Queued { get; set; } + + [JsonProperty("connecting")] + public int Connecting { get; set; } + + [JsonProperty("live")] + public int Live { get; set; } + + [JsonProperty("seen")] + public int Seen { get; set; } + + [JsonProperty("dead")] + public int Dead { get; set; } + + [JsonProperty("not_needed")] + public int NotNeeded { get; set; } + + [JsonProperty("steals")] + public int Steals { get; set; } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 23a3b71ab8..7b66a10ef0 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -544,6 +544,8 @@ "DownloadClientTransmissionSettingsUrlBaseHelpText": "Adds a prefix to the {clientName} rpc url, eg {url}, defaults to '{defaultUrl}'", "DownloadClientUTorrentProviderMessage": "uTorrent has a history of including cryptominers, malware and ads, we strongly encourage you to choose a different client.", "DownloadClientUTorrentTorrentStateError": "uTorrent is reporting an error", + "DownloadClientRQbitSettingsUseSslHelpText": "Use SSL when connecting to RQBit", + "DownloadClientRQbitSettingsUrlBaseHelpText": "Adds a prefix to the RQBit API, such as /rqbit", "DownloadClientUnavailable": "Download Client Unavailable", "DownloadClientValidationApiKeyIncorrect": "API Key Incorrect", "DownloadClientValidationApiKeyRequired": "API Key Required",