From eec3efdb317bdfc81d1a18c0d3af76d6d10cb096 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 7 Jan 2026 05:13:25 +0200 Subject: [PATCH] New: API key support for qBittorrent --- .../Clients/QBittorrent/QBittorrent.cs | 3 +- .../Clients/QBittorrent/QBittorrentProxyV2.cs | 45 +++++++++++++++---- .../QBittorrent/QBittorrentSettings.cs | 30 ++++++++----- src/NzbDrone.Core/Localization/Core/en.json | 2 +- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index f5c9fe450d..3cd859db0b 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -476,7 +476,8 @@ private ValidationFailure TestConnection() catch (DownloadClientAuthenticationException ex) { _logger.Error(ex, ex.Message); - return new NzbDroneValidationFailure("Username", _localizationService.GetLocalizedString("DownloadClientValidationAuthenticationFailure")) + + return new NzbDroneValidationFailure(Settings.ApiKey.IsNotNullOrWhiteSpace() ? "ApiKey" : "Username", _localizationService.GetLocalizedString("DownloadClientValidationAuthenticationFailure")) { DetailedDescription = _localizationService.GetLocalizedString("DownloadClientValidationAuthenticationFailureDetail", new Dictionary { { "clientName", Name } }) }; diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index 54ec66a7ac..e2e9743c68 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -337,13 +337,19 @@ public void SetForceStart(string hash, bool enabled, QBittorrentSettings setting ProcessRequest(request, settings); } - private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) + private static HttpRequestBuilder BuildRequest(QBittorrentSettings settings) { var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) { LogResponseContent = true, StoreRequestCookie = false }; + + if (settings.ApiKey.IsNotNullOrWhiteSpace()) + { + requestBuilder.Headers["Authorization"] = $"Bearer {settings.ApiKey}"; + } + return requestBuilder; } @@ -357,16 +363,39 @@ private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBitt private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) { - AuthenticateClient(requestBuilder, settings); - var request = requestBuilder.Build(); request.LogResponseContent = true; + + if (settings.ApiKey.IsNotNullOrWhiteSpace()) + { + try + { + return _httpClient.Execute(request).Content; + } + catch (HttpException ex) + { + if (ex.Response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + _logger.Debug(ex, "qbitTorrent authentication failed."); + + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + catch (Exception ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + } + + AuthenticateClient(requestBuilder, settings); + request.SuppressHttpErrorStatusCodes = new[] { HttpStatusCode.Forbidden }; - HttpResponse response; try { - response = _httpClient.Execute(request); + var response = _httpClient.Execute(request); if (response.StatusCode == HttpStatusCode.Forbidden) { @@ -378,17 +407,17 @@ private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSett response = _httpClient.Execute(request); } + + return response.Content; } catch (HttpException ex) { throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); } - catch (WebException ex) + catch (Exception ex) { throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); } - - return response.Content; } private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs index 7e057ddb8e..6e48e673eb 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs @@ -13,6 +13,13 @@ public QBittorrentSettingsValidator() RuleFor(c => c.Port).InclusiveBetween(1, 65535); RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + RuleFor(c => c.Username).Empty() + .WithMessage("Username must be empty when using API Key.") + .When(c => c.ApiKey.IsNotNullOrWhiteSpace()); + RuleFor(c => c.Password).Empty() + .WithMessage("Password must be empty when using API Key.") + .When(c => c.ApiKey.IsNotNullOrWhiteSpace()); + RuleFor(c => c.MovieCategory).Matches(@"^([^\\\/](\/?[^\\\/])*)?$").WithMessage(@"Can not contain '\', '//', or start/end with '/'"); RuleFor(c => c.MovieImportedCategory).Matches(@"^([^\\\/](\/?[^\\\/])*)?$").WithMessage(@"Can not contain '\', '//', or start/end with '/'"); } @@ -43,34 +50,37 @@ public QBittorrentSettings() [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } - [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + [FieldDefinition(4, Label = "ApiKey", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] + public string ApiKey { get; set; } + + [FieldDefinition(5, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] public string Username { get; set; } - [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + [FieldDefinition(6, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsCategoryHelpText")] + [FieldDefinition(7, Label = "Category", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsCategoryHelpText")] public string MovieCategory { get; set; } - [FieldDefinition(7, Label = "PostImportCategory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsPostImportCategoryHelpText")] + [FieldDefinition(8, Label = "PostImportCategory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsPostImportCategoryHelpText")] public string MovieImportedCategory { get; set; } - [FieldDefinition(8, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "DownloadClientSettingsRecentPriorityMovieHelpText")] + [FieldDefinition(9, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "DownloadClientSettingsRecentPriorityEpisodeHelpText")] public int RecentMoviePriority { get; set; } - [FieldDefinition(9, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "DownloadClientSettingsOlderPriorityMovieHelpText")] + [FieldDefinition(10, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "DownloadClientSettingsOlderPriorityEpisodeHelpText")] public int OlderMoviePriority { get; set; } - [FieldDefinition(10, Label = "DownloadClientSettingsInitialState", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "DownloadClientQbittorrentSettingsInitialStateHelpText")] + [FieldDefinition(11, Label = "DownloadClientSettingsInitialState", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "DownloadClientQbittorrentSettingsInitialStateHelpText")] public int InitialState { get; set; } - [FieldDefinition(11, Label = "DownloadClientQbittorrentSettingsSequentialOrder", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsSequentialOrderHelpText")] + [FieldDefinition(12, Label = "DownloadClientQbittorrentSettingsSequentialOrder", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsSequentialOrderHelpText")] public bool SequentialOrder { get; set; } - [FieldDefinition(12, Label = "DownloadClientQbittorrentSettingsFirstAndLastFirst", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText")] + [FieldDefinition(13, Label = "DownloadClientQbittorrentSettingsFirstAndLastFirst", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText")] public bool FirstAndLast { get; set; } - [FieldDefinition(13, Label = "DownloadClientQbittorrentSettingsContentLayout", Type = FieldType.Select, SelectOptions = typeof(QBittorrentContentLayout), HelpText = "DownloadClientQbittorrentSettingsContentLayoutHelpText")] + [FieldDefinition(14, Label = "DownloadClientQbittorrentSettingsContentLayout", Type = FieldType.Select, SelectOptions = typeof(QBittorrentContentLayout), HelpText = "DownloadClientQbittorrentSettingsContentLayoutHelpText")] public int ContentLayout { get; set; } public override NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 23a3b71ab8..283f20cade 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -548,7 +548,7 @@ "DownloadClientValidationApiKeyIncorrect": "API Key Incorrect", "DownloadClientValidationApiKeyRequired": "API Key Required", "DownloadClientValidationAuthenticationFailure": "Authentication Failure", - "DownloadClientValidationAuthenticationFailureDetail": "Please verify your username and password. Also verify if the host running {appName} isn't blocked from accessing {clientName} by WhiteList limitations in the {clientName} configuration.", + "DownloadClientValidationAuthenticationFailureDetail": "Please verify your credentials. Also verify if the host running {appName} isn't blocked from accessing {clientName} by WhiteList limitations in the {clientName} configuration.", "DownloadClientValidationCategoryMissing": "Category does not exist", "DownloadClientValidationCategoryMissingDetail": "The category you entered doesn't exist in {clientName}. Create it in {clientName} first.", "DownloadClientValidationErrorVersion": "{clientName} version should be at least {requiredVersion}. Version reported is {reportedVersion}",