From 974e44ce48fb0d19e277fcd0a5b5c8e51571f80e Mon Sep 17 00:00:00 2001 From: Qstick Date: Tue, 21 Dec 2021 22:48:20 -0600 Subject: [PATCH] New: Link indexer to specific download client --- .../DownloadClientSelectInputConnector.js | 100 ++++++++++++++++++ .../src/Components/Form/FormInputGroup.js | 4 + frontend/src/Helpers/Props/inputTypes.js | 2 + .../Indexers/EditIndexerModalContent.js | 21 +++- .../Download/DownloadClientProviderFixture.cs | 48 ++++++++- .../Download/DownloadServiceFixture.cs | 4 +- .../Datastore/BasicRepository.cs | 8 ++ .../205_download_client_per_indexer.cs | 14 +++ .../Download/DownloadClientProvider.cs | 27 ++++- src/NzbDrone.Core/Download/DownloadService.cs | 2 +- .../Indexers/IndexerDefinition.cs | 3 +- src/NzbDrone.Core/Localization/Core/en.json | 1 + .../ThingiProvider/IProviderFactory.cs | 2 + .../ThingiProvider/ProviderFactory.cs | 10 ++ src/Radarr.Api.V3/Indexers/IndexerResource.cs | 3 + 15 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 frontend/src/Components/Form/DownloadClientSelectInputConnector.js create mode 100644 src/NzbDrone.Core/Datastore/Migration/205_download_client_per_indexer.cs diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js new file mode 100644 index 000000000..a83986d8f --- /dev/null +++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js @@ -0,0 +1,100 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import sortByName from 'Utilities/Array/sortByName'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (state, { includeAny }) => includeAny, + (state, { protocol }) => protocol, + (downloadClients, includeAny, protocolFilter) => { + const { + isFetching, + isPopulated, + error, + items + } = downloadClients; + + const filteredItems = items.filter((item) => item.protocol === protocolFilter); + + const values = _.map(filteredItems.sort(sortByName), (downloadClient) => { + return { + key: downloadClient.id, + value: downloadClient.name + }; + }); + + if (includeAny) { + values.unshift({ + key: 0, + value: '(Any)' + }); + } + + return { + isFetching, + isPopulated, + error, + values + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDownloadClients: fetchDownloadClients +}; + +class DownloadClientSelectInputConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchDownloadClients(); + } + } + + // + // Listeners + + onChange = ({ name, value }) => { + this.props.onChange({ name, value: parseInt(value) }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientSelectInputConnector.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + includeAny: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired +}; + +DownloadClientSelectInputConnector.defaultProps = { + includeAny: false, + protocol: 'torrent' +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector); diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 1c50e3398..5327fe616 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -8,6 +8,7 @@ import AvailabilitySelectInput from './AvailabilitySelectInput'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; import DeviceInputConnector from './DeviceInputConnector'; +import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector'; import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInputConnector from './EnhancedSelectInputConnector'; import FormInputHelpText from './FormInputHelpText'; @@ -73,6 +74,9 @@ function getComponent(type) { case inputTypes.INDEXER_FLAGS_SELECT: return IndexerFlagsSelectInputConnector; + case inputTypes.DOWNLOAD_CLIENT_SELECT: + return DownloadClientSelectInputConnector; + case inputTypes.LANGUAGE_SELECT: return LanguageSelectInputConnector; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index cd4ef89d9..ac031d4f0 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -13,6 +13,7 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const LANGUAGE_SELECT = 'languageSelect'; +export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const SELECT = 'select'; export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; @@ -35,6 +36,7 @@ export const all = [ PASSWORD, PATH, QUALITY_PROFILE_SELECT, + DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, INDEXER_FLAGS_SELECT, LANGUAGE_SELECT, diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index aac13e5c2..0d0aae42c 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -45,7 +45,9 @@ function EditIndexerModalContent(props) { supportsSearch, tags, fields, - priority + priority, + protocol, + downloadClientId } = item; return ( @@ -154,6 +156,23 @@ function EditIndexerModalContent(props) { /> + + {translate('DownloadClient')} + + + + Tags diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs index 1387ec3fd..0e6973152 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Indexers; using NzbDrone.Core.Test.Framework; @@ -67,6 +68,17 @@ private Mock WithTorrentClient(int priority = 0) return mock; } + private void WithTorrentIndexer(int downloadClientId) + { + Mocker.GetMock() + .Setup(v => v.Find(It.IsAny())) + .Returns(Builder + .CreateNew() + .With(v => v.Id = _nextId++) + .With(v => v.DownloadClientId = downloadClientId) + .Build()); + } + private void GivenBlockedClient(int id) { _blockedProviders.Add(new DownloadClientStatus @@ -223,5 +235,39 @@ public void should_not_skip_secondary_prio_torrent_client_if_primary_blocked() client3.Definition.Id.Should().Be(2); client4.Definition.Id.Should().Be(3); } + + [Test] + public void should_always_choose_indexer_client() + { + WithUsenetClient(); + WithTorrentClient(); + WithTorrentClient(); + WithTorrentClient(); + WithTorrentIndexer(3); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 1); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 1); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 1); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 1); + var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 1); + + client1.Definition.Id.Should().Be(3); + client2.Definition.Id.Should().Be(3); + client3.Definition.Id.Should().Be(3); + client4.Definition.Id.Should().Be(3); + client5.Definition.Id.Should().Be(3); + } + + [Test] + public void should_fail_to_choose_client_when_indexer_reference_does_not_exist() + { + WithUsenetClient(); + WithTorrentClient(); + WithTorrentClient(); + WithTorrentClient(); + WithTorrentIndexer(5); + + Assert.Throws(() => Subject.GetDownloadClient(DownloadProtocol.Torrent, 1)); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs index 6831f5701..55c02bca8 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -31,8 +31,8 @@ public void Setup() .Returns(_downloadClients); Mocker.GetMock() - .Setup(v => v.GetDownloadClient(It.IsAny())) - .Returns(v => _downloadClients.FirstOrDefault(d => d.Protocol == v)); + .Setup(v => v.GetDownloadClient(It.IsAny(), It.IsAny())) + .Returns((v, i) => _downloadClients.FirstOrDefault(d => d.Protocol == v)); var releaseInfo = Builder.CreateNew() .With(v => v.DownloadProtocol = DownloadProtocol.Usenet) diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index 8367acf05..33527e89f 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -17,6 +17,7 @@ public interface IBasicRepository IEnumerable All(); int Count(); TModel Get(int id); + TModel Find(int id); TModel Insert(TModel model); TModel Update(TModel model); TModel Upsert(TModel model); @@ -99,6 +100,13 @@ public TModel Get(int id) return model; } + public TModel Find(int id) + { + var model = Query(c => c.Id == id).SingleOrDefault(); + + return model; + } + public IEnumerable Get(IEnumerable ids) { if (!ids.Any()) diff --git a/src/NzbDrone.Core/Datastore/Migration/205_download_client_per_indexer.cs b/src/NzbDrone.Core/Datastore/Migration/205_download_client_per_indexer.cs new file mode 100644 index 000000000..7baf89546 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/205_download_client_per_indexer.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(205)] + public class download_client_per_indexer : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Indexers").AddColumn("DownloadClientId").AsInt32().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 3ad8f6615..3fa2bcc39 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -1,14 +1,15 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Indexers; namespace NzbDrone.Core.Download { public interface IProvideDownloadClient { - IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol); + IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0); IEnumerable GetDownloadClients(); IDownloadClient Get(int id); } @@ -18,17 +19,23 @@ public class DownloadClientProvider : IProvideDownloadClient private readonly Logger _logger; private readonly IDownloadClientFactory _downloadClientFactory; private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly IIndexerFactory _indexerFactory; private readonly ICached _lastUsedDownloadClient; - public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, IDownloadClientFactory downloadClientFactory, ICacheManager cacheManager, Logger logger) + public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, + IDownloadClientFactory downloadClientFactory, + IIndexerFactory indexerFactory, + ICacheManager cacheManager, + Logger logger) { _logger = logger; _downloadClientFactory = downloadClientFactory; _downloadClientStatusService = downloadClientStatusService; + _indexerFactory = indexerFactory; _lastUsedDownloadClient = cacheManager.GetCache(GetType(), "lastDownloadClientId"); } - public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol) + public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0) { var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList(); @@ -37,6 +44,18 @@ public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol) return null; } + if (indexerId > 0) + { + var indexer = _indexerFactory.Find(indexerId); + + if (indexer != null && indexer.DownloadClientId > 0) + { + var client = availableProviders.SingleOrDefault(d => d.Definition.Id == indexer.DownloadClientId); + + return client ?? throw new DownloadClientUnavailableException($"Indexer specified download client is not available"); + } + } + var blockedProviders = new HashSet(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId)); if (blockedProviders.Any()) diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 48395ab18..4b01ffd98 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -50,7 +50,7 @@ public void DownloadReport(RemoteMovie remoteMovie) Ensure.That(remoteMovie.Movie, () => remoteMovie.Movie).IsNotNull(); var downloadTitle = remoteMovie.Release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteMovie.Release.DownloadProtocol); + var downloadClient = _downloadClientProvider.GetDownloadClient(remoteMovie.Release.DownloadProtocol, remoteMovie.Release.IndexerId); if (downloadClient == null) { diff --git a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index d43c18198..a6ed69130 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers { @@ -7,6 +7,7 @@ public class IndexerDefinition : ProviderDefinition public bool EnableRss { get; set; } public bool EnableAutomaticSearch { get; set; } public bool EnableInteractiveSearch { get; set; } + public int DownloadClientId { get; set; } public DownloadProtocol Protocol { get; set; } public bool SupportsRss { get; set; } public bool SupportsSearch { get; set; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 9c312ed64..367e6ea2d 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -421,6 +421,7 @@ "IncludeUnknownMovieItemsHelpText": "Show items without a movie in the queue. This could include removed movies or anything else in Radarr's category", "IncludeUnmonitored": "Include Unmonitored", "Indexer": "Indexer", + "IndexerDownloadClientHelpText": "Specify which download client is used for grabs from this indexer", "IndexerFlags": "Indexer Flags", "IndexerLongTermStatusCheckAllClientMessage": "All indexers are unavailable due to failures for more than 6 hours", "IndexerLongTermStatusCheckSingleClientMessage": "Indexers unavailable due to failures for more than 6 hours: {0}", diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index bc52f9e16..3945d2a69 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -9,6 +9,8 @@ public interface IProviderFactory { List All(); List GetAvailableProviders(); + bool Exists(int id); + TProviderDefinition Find(int id); TProviderDefinition Get(int id); TProviderDefinition Create(TProviderDefinition definition); void Update(TProviderDefinition definition); diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index 35ec3d557..dd51a9d7a 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -92,11 +92,21 @@ public List GetAvailableProviders() return Active().Select(GetInstance).ToList(); } + public bool Exists(int id) + { + return _providerRepository.Find(id) != null; + } + public TProviderDefinition Get(int id) { return _providerRepository.Get(id); } + public TProviderDefinition Find(int id) + { + return _providerRepository.Find(id); + } + public virtual TProviderDefinition Create(TProviderDefinition definition) { var addedDefinition = _providerRepository.Insert(definition); diff --git a/src/Radarr.Api.V3/Indexers/IndexerResource.cs b/src/Radarr.Api.V3/Indexers/IndexerResource.cs index f70852c2c..c5e02c2c2 100644 --- a/src/Radarr.Api.V3/Indexers/IndexerResource.cs +++ b/src/Radarr.Api.V3/Indexers/IndexerResource.cs @@ -11,6 +11,7 @@ public class IndexerResource : ProviderResource public bool SupportsSearch { get; set; } public DownloadProtocol Protocol { get; set; } public int Priority { get; set; } + public int DownloadClientId { get; set; } } public class IndexerResourceMapper : ProviderResourceMapper @@ -31,6 +32,7 @@ public override IndexerResource ToResource(IndexerDefinition definition) resource.SupportsSearch = definition.SupportsSearch; resource.Protocol = definition.Protocol; resource.Priority = definition.Priority; + resource.DownloadClientId = definition.DownloadClientId; return resource; } @@ -48,6 +50,7 @@ public override IndexerDefinition ToModel(IndexerResource resource) definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; definition.EnableInteractiveSearch = resource.EnableInteractiveSearch; definition.Priority = resource.Priority; + definition.DownloadClientId = resource.DownloadClientId; return definition; }