From 13aaa20f1bf1448fa804738804205cb16f0d91f9 Mon Sep 17 00:00:00 2001 From: Qiming Chen Date: Sun, 23 Jan 2022 12:58:23 -0800 Subject: [PATCH] New: Link indexer to specific download client Closes #1215 Co-authored-by: Qstick --- .../DownloadClientSelectInputConnector.js | 100 ++++++++++++++++++ .../src/Components/Form/FormInputGroup.js | 4 + frontend/src/Helpers/Props/inputTypes.js | 2 + .../Indexers/EditIndexerModalContent.js | 21 +++- .../Download/DownloadClientProviderFixture.cs | 46 ++++++++ .../Download/DownloadServiceFixture.cs | 4 +- .../164_download_client_per_indexer.cs | 14 +++ .../Download/DownloadClientProvider.cs | 25 ++++- src/NzbDrone.Core/Download/DownloadService.cs | 2 +- .../Indexers/IndexerDefinition.cs | 1 + .../ThingiProvider/IProviderFactory.cs | 1 + .../ThingiProvider/ProviderFactory.cs | 5 + src/Sonarr.Api.V3/Indexers/IndexerResource.cs | 3 + 13 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 frontend/src/Components/Form/DownloadClientSelectInputConnector.js create mode 100644 src/NzbDrone.Core/Datastore/Migration/164_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 89e1e81ea..f1bd6c0f2 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -6,6 +6,7 @@ import AutoCompleteInput from './AutoCompleteInput'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; import DeviceInputConnector from './DeviceInputConnector'; +import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector'; import KeyValueListInput from './KeyValueListInput'; import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput'; import NumberInput from './NumberInput'; @@ -68,6 +69,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 b38bc9096..f87033c9b 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -11,6 +11,7 @@ export const PATH = 'path'; export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect'; 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'; @@ -35,6 +36,7 @@ export const all = [ QUALITY_PROFILE_SELECT, LANGUAGE_PROFILE_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 159c7085f..2af9e6165 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -44,7 +44,9 @@ function EditIndexerModalContent(props) { supportsSearch, tags, fields, - priority + priority, + protocol, + downloadClientId } = item; return ( @@ -151,6 +153,23 @@ function EditIndexerModalContent(props) { /> + + DownloadClient + + + + Tags diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs index 27fe13b81..9ff0691bd 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs @@ -7,6 +7,7 @@ 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; @@ -69,6 +70,17 @@ namespace NzbDrone.Core.Test.Download 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 @@ -225,5 +237,39 @@ namespace NzbDrone.Core.Test.Download 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 1b49ef811..cab05ed5c 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -33,8 +33,8 @@ namespace NzbDrone.Core.Test.Download .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/164_download_client_per_indexer.cs b/src/NzbDrone.Core/Datastore/Migration/164_download_client_per_indexer.cs new file mode 100644 index 000000000..44f4c5b1d --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/164_download_client_per_indexer.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(164)] + public class download_client_per_indexer : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Indexers").AddColumn("DownloadClientId").AsInt32().WithDefaultValue(0); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 978f9ea60..d778c01e1 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -2,13 +2,14 @@ using System.Collections.Generic; using NzbDrone.Core.Indexers; using NzbDrone.Common.Cache; +using NzbDrone.Core.Download.Clients; using NLog; 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,22 +19,40 @@ namespace NzbDrone.Core.Download 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(); if (!availableProviders.Any()) 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 8c9db6596..bb09c758d 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Download Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems(); var downloadTitle = remoteEpisode.Release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol); + var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.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 @@ namespace NzbDrone.Core.Indexers 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/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index 14c47d6e3..6889d22e9 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -10,6 +10,7 @@ namespace NzbDrone.Core.ThingiProvider 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 7167a17d9..a8af11ca1 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -101,6 +101,11 @@ namespace NzbDrone.Core.ThingiProvider 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/Sonarr.Api.V3/Indexers/IndexerResource.cs b/src/Sonarr.Api.V3/Indexers/IndexerResource.cs index 21865d413..1211bdf9d 100644 --- a/src/Sonarr.Api.V3/Indexers/IndexerResource.cs +++ b/src/Sonarr.Api.V3/Indexers/IndexerResource.cs @@ -11,6 +11,7 @@ namespace Sonarr.Api.V3.Indexers public bool SupportsSearch { get; set; } public DownloadProtocol Protocol { get; set; } public int Priority { get; set; } + public int DownloadClientId { get; set; } } public class IndexerResourceMapper : ProviderResourceMapper @@ -28,6 +29,7 @@ namespace Sonarr.Api.V3.Indexers resource.SupportsSearch = definition.SupportsSearch; resource.Protocol = definition.Protocol; resource.Priority = definition.Priority; + resource.DownloadClientId = definition.DownloadClientId; return resource; } @@ -42,6 +44,7 @@ namespace Sonarr.Api.V3.Indexers definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; definition.EnableInteractiveSearch = resource.EnableInteractiveSearch; definition.Priority = resource.Priority; + definition.DownloadClientId = resource.DownloadClientId; return definition; }