diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js new file mode 100644 index 000000000..c89016869 --- /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 7306b4bcf..e38d7970e 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -10,6 +10,7 @@ import BookshelfInputConnector from './BookshelfInputConnector'; 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'; @@ -81,6 +82,9 @@ function getComponent(type) { case inputTypes.INDEXER_SELECT: return IndexerSelectInputConnector; + case inputTypes.DOWNLOAD_CLIENT_SELECT: + return DownloadClientSelectInputConnector; + case inputTypes.ROOT_FOLDER_SELECT: return RootFolderSelectInputConnector; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 4e96cb2f3..c4cfb7ecf 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -14,6 +14,7 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; export const BOOK_EDITION_SELECT = 'bookEditionSelect'; export const INDEXER_SELECT = 'indexerSelect'; +export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const DYNAMIC_SELECT = 'dynamicSelect'; @@ -40,6 +41,7 @@ export const all = [ METADATA_PROFILE_SELECT, BOOK_EDITION_SELECT, INDEXER_SELECT, + DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, SELECT, DYNAMIC_SELECT, diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index 46f004add..ab968c005 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 ( @@ -165,6 +167,23 @@ function EditIndexerModalContent(props) { /> + + {translate('DownloadClient')} + + + + {translate('Tags')} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs index 1387ec3fd..9545801be 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs @@ -5,6 +5,7 @@ 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 fa0c40c7f..46d19add8 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 episodes = Builder.CreateListOfSize(2) .TheFirst(1).With(s => s.Id = 12) diff --git a/src/NzbDrone.Core/Datastore/Migration/030_download_client_per_indexer.cs b/src/NzbDrone.Core/Datastore/Migration/030_download_client_per_indexer.cs new file mode 100644 index 000000000..a17305e54 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/030_download_client_per_indexer.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(030)] + 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 882666ed0..df9249dcc 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -2,13 +2,14 @@ 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(bool filterBlockedClients = false); 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 1a22e6266..6687ac20e 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -54,7 +54,7 @@ public void DownloadReport(RemoteBook remoteBook) Ensure.That(remoteBook.Books, () => remoteBook.Books).HasItems(); var downloadTitle = remoteBook.Release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteBook.Release.DownloadProtocol); + var downloadClient = _downloadClientProvider.GetDownloadClient(remoteBook.Release.DownloadProtocol, remoteBook.Release.IndexerId); if (downloadClient == null) { diff --git a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index 3bcd56b52..a6ed69130 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -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 4c75d95bd..bcae2ad72 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -361,6 +361,7 @@ "IncludeUnknownAuthorItemsHelpText": "Show items without a author in the queue, this could include removed authors, books or anything else in Readarr's category", "IncludeUnmonitored": "Include Unmonitored", "Indexer": "Indexer", + "IndexerDownloadClientHelpText": "Specify which download client is used for grabs from this indexer", "IndexerIdHelpText": "Specify what indexer the profile applies to", "IndexerIdHelpTextWarning": "Using a specific indexer with preferred words can lead to duplicate releases being grabbed", "IndexerJackettAll": "Indexers using the unsupported Jackett 'all' endpoint: {0}", diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index 308299cb1..0196d1858 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -10,6 +10,7 @@ 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 e3feea13b..5220ecd1c 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -101,6 +101,11 @@ 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 result = _providerRepository.Insert(definition); diff --git a/src/Readarr.Api.V1/Indexers/IndexerResource.cs b/src/Readarr.Api.V1/Indexers/IndexerResource.cs index a0c363816..5a30dbf18 100644 --- a/src/Readarr.Api.V1/Indexers/IndexerResource.cs +++ b/src/Readarr.Api.V1/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; }