From 9b120f48851ac484d67a4e49ed52ca3c4e6da7e4 Mon Sep 17 00:00:00 2001 From: Kai Yang Date: Tue, 7 Jun 2022 11:06:40 +0800 Subject: [PATCH] New: Link indexer to specific download client (#2668) * New: Link indexer to specific download client Closes #1215 Co-authored-by: Qstick (cherry picked from commit 13aaa20f1bf1448fa804738804205cb16f0d91f9) * lint Co-authored-by: Qiming Chen --- .../DownloadClientSelectInputConnector.js | 100 ++++++++++++++++++ .../src/Components/Form/FormInputGroup.js | 4 + frontend/src/Helpers/Props/inputTypes.js | 2 + .../Indexers/EditIndexerModalContent.js | 21 +++- src/Lidarr.Api.V1/Indexers/IndexerResource.cs | 3 + .../Download/DownloadClientProviderFixture.cs | 46 ++++++++ .../Download/DownloadServiceFixture.cs | 4 +- .../055_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 + 13 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 frontend/src/Components/Form/DownloadClientSelectInputConnector.js create mode 100644 src/NzbDrone.Core/Datastore/Migration/055_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..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 4919e5027..827b346c2 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -7,6 +7,7 @@ import AutoCompleteInput from './AutoCompleteInput'; 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'; @@ -80,6 +81,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 2f04d5213..8710ff483 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 ALBUM_RELEASE_SELECT = 'albumReleaseSelect'; export const INDEXER_SELECT = 'indexerSelect'; +export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const SERIES_TYPE_SELECT = 'artistTypeSelect'; @@ -41,6 +42,7 @@ export const all = [ METADATA_PROFILE_SELECT, ALBUM_RELEASE_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 52971d20d..112c06948 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -44,7 +44,9 @@ function EditIndexerModalContent(props) { supportsRss, supportsSearch, fields, - priority + priority, + protocol, + downloadClientId } = item; return ( @@ -161,6 +163,23 @@ function EditIndexerModalContent(props) { onChange={onInputChange} /> + + + DownloadClient + + + } diff --git a/src/Lidarr.Api.V1/Indexers/IndexerResource.cs b/src/Lidarr.Api.V1/Indexers/IndexerResource.cs index 0b1825861..c5c9589eb 100644 --- a/src/Lidarr.Api.V1/Indexers/IndexerResource.cs +++ b/src/Lidarr.Api.V1/Indexers/IndexerResource.cs @@ -11,6 +11,7 @@ namespace Lidarr.Api.V1.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 @@ -31,6 +32,7 @@ namespace Lidarr.Api.V1.Indexers resource.SupportsSearch = definition.SupportsSearch; resource.Protocol = definition.Protocol; resource.Priority = definition.Priority; + resource.DownloadClientId = definition.DownloadClientId; return resource; } @@ -48,6 +50,7 @@ namespace Lidarr.Api.V1.Indexers definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; definition.EnableInteractiveSearch = resource.EnableInteractiveSearch; definition.Priority = resource.Priority; + definition.DownloadClientId = resource.DownloadClientId; return definition; } 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 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 @@ 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 @@ -223,5 +235,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 3fd0da73b..d522a15c0 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -31,8 +31,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/055_download_client_per_indexer.cs b/src/NzbDrone.Core/Datastore/Migration/055_download_client_per_indexer.cs new file mode 100644 index 000000000..4eed043cb --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/055_download_client_per_indexer.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(055)] + 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 fb321d8b5..3fa2bcc39 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -2,13 +2,14 @@ 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 @@ 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(); @@ -37,6 +44,18 @@ namespace NzbDrone.Core.Download 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 8872aef7c..66ec2c25e 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Download Ensure.That(remoteAlbum.Albums, () => remoteAlbum.Albums).HasItems(); var downloadTitle = remoteAlbum.Release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteAlbum.Release.DownloadProtocol); + var downloadClient = _downloadClientProvider.GetDownloadClient(remoteAlbum.Release.DownloadProtocol, remoteAlbum.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 308299cb1..0196d1858 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 fac049f45..dd51a9d7a 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -102,6 +102,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 addedDefinition = _providerRepository.Insert(definition);