diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js index 8cea557a9e..2470c1cdd2 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import Card from 'Components/Card'; import Label from 'Components/Label'; import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagList from 'Components/TagList'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; @@ -56,7 +57,9 @@ class DownloadClient extends Component { id, name, enable, - priority + priority, + tags, + tagList } = this.props; return ( @@ -94,6 +97,11 @@ class DownloadClient extends Component { } + + ); @@ -109,6 +111,7 @@ DownloadClients.propTypes = { isFetching: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, onConfirmDeleteDownloadClient: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js index 9cba9c1cc3..d9e5434691 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -4,13 +4,20 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; import sortByName from 'Utilities/Array/sortByName'; import DownloadClients from './DownloadClients'; function createMapStateToProps() { return createSelector( createSortedSectionSelector('settings.downloadClients', sortByName), - (downloadClients) => downloadClients + createTagsSelector(), + (downloadClients, tagList) => { + return { + ...downloadClients, + tagList + }; + } ); } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index b2238f58eb..cb03acb98e 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -51,6 +51,7 @@ class EditDownloadClientModalContent extends Component { removeCompletedDownloads, removeFailedDownloads, fields, + tags, message } = item; @@ -140,6 +141,18 @@ class EditDownloadClientModalContent extends Component { /> + + {translate('Tags')} + + + +
{ + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditDownloadClients({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + const onSelectAllChange = useCallback( ({ value }: SelectStateInputProps) => { setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); @@ -222,6 +255,14 @@ function ManageDownloadClientsModalContent( > {translate('Edit')} + + + {translate('SetTags')} + @@ -234,6 +275,13 @@ function ManageDownloadClientsModalContent( downloadClientIds={selectedIds} /> + + {removeFailedDownloads ? translate('Yes') : translate('No')} + + + + ); } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModal.tsx new file mode 100644 index 0000000000..2e24d60e88 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css new file mode 100644 index 0000000000..63be9aaddb --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 0000000000..9b4321dcc6 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 0000000000..23b52d50f0 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,185 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import DownloadClient from 'typings/DownloadClient'; +import translate from 'Utilities/String/translate'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allDownloadClients: DownloadClientAppState = useSelector( + (state: AppState) => state.settings.downloadClients + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const downloadClientsTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allDownloadClients.items.find( + (s: DownloadClient) => s.id === id + ); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allDownloadClients]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: translate('Add') }, + { key: 'remove', value: translate('Remove') }, + { key: 'replace', value: translate('Replace') }, + ]; + + return ( + + {translate('Tags')} + + +
+ + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('Result')} + +
+ {downloadClientsTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (downloadClientsTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 0e16881d24..e946afdf47 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -23,6 +23,7 @@ function TagDetailsModalContent(props) { restrictions, importLists, indexers, + downloadClients, onModalClose, onDeleteTagPress } = props; @@ -167,7 +168,7 @@ function TagDetailsModalContent(props) { } { - !!importLists.length && + importLists.length ?
{ importLists.map((item) => { @@ -178,7 +179,24 @@ function TagDetailsModalContent(props) { ); }) } -
+
: + null + } + + { + downloadClients.length ? +
+ { + downloadClients.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
: + null } @@ -214,6 +232,7 @@ TagDetailsModalContent.propTypes = { restrictions: PropTypes.arrayOf(PropTypes.object).isRequired, importLists: PropTypes.arrayOf(PropTypes.object).isRequired, indexers: PropTypes.arrayOf(PropTypes.object).isRequired, + downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, onModalClose: PropTypes.func.isRequired, onDeleteTagPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js index 0aa51a71cd..a8d1e8ffcb 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -77,6 +77,14 @@ function createMatchingIndexersSelector() { ); } +function createMatchingDownloadClientsSelector() { + return createSelector( + (state, { downloadClientIds }) => downloadClientIds, + (state) => state.settings.downloadClients.items, + findMatchingItems + ); +} + function createMapStateToProps() { return createSelector( createMatchingMoviesSelector(), @@ -85,14 +93,16 @@ function createMapStateToProps() { createMatchingRestrictionsSelector(), createMatchingImportListsSelector(), createMatchingIndexersSelector(), - (movies, delayProfiles, notifications, restrictions, importLists, indexers) => { + createMatchingDownloadClientsSelector(), + (movies, delayProfiles, notifications, restrictions, importLists, indexers, downloadClients) => { return { movies, delayProfiles, notifications, restrictions, importLists, - indexers + indexers, + downloadClients }; } ); diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 95cff9e774..1d4f419a07 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -58,7 +58,8 @@ class Tag extends Component { restrictionIds, importListIds, movieIds, - indexerIds + indexerIds, + downloadClientIds } = this.props; const { @@ -72,7 +73,8 @@ class Tag extends Component { restrictionIds.length || importListIds.length || movieIds.length || - indexerIds.length + indexerIds.length || + downloadClientIds.length ); return ( @@ -130,6 +132,14 @@ class Tag extends Component { : null } + + { + downloadClientIds.length ? +
+ {downloadClientIds.length} download client{indexerIds.length > 1 && 's'} +
: + null + } } @@ -149,6 +159,7 @@ class Tag extends Component { restrictionIds={restrictionIds} importListIds={importListIds} indexerIds={indexerIds} + downloadClientIds={downloadClientIds} isOpen={isDetailsModalOpen} onModalClose={this.onDetailsModalClose} onDeleteTagPress={this.onDeleteTagPress} @@ -177,6 +188,7 @@ Tag.propTypes = { importListIds: PropTypes.arrayOf(PropTypes.number).isRequired, movieIds: PropTypes.arrayOf(PropTypes.number).isRequired, indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired, + downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired, onConfirmDeleteTag: PropTypes.func.isRequired }; @@ -186,7 +198,8 @@ Tag.defaultProps = { restrictionIds: [], importListIds: [], movieIds: [], - indexerIds: [] + indexerIds: [], + downloadClientIds: [] }; export default Tag; diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index a3ed6b8c65..d60b645de6 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { fetchDelayProfiles, fetchImportLists, fetchIndexers, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions'; +import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions'; import { fetchTagDetails } from 'Store/Actions/tagActions'; import Tags from './Tags'; @@ -30,7 +30,8 @@ const mapDispatchToProps = { dispatchFetchNotifications: fetchNotifications, dispatchFetchRestrictions: fetchRestrictions, dispatchFetchImportLists: fetchImportLists, - dispatchFetchIndexers: fetchIndexers + dispatchFetchIndexers: fetchIndexers, + dispatchFetchDownloadClients: fetchDownloadClients }; class MetadatasConnector extends Component { @@ -45,7 +46,8 @@ class MetadatasConnector extends Component { dispatchFetchNotifications, dispatchFetchRestrictions, dispatchFetchImportLists, - dispatchFetchIndexers + dispatchFetchIndexers, + dispatchFetchDownloadClients } = this.props; dispatchFetchTagDetails(); @@ -54,6 +56,7 @@ class MetadatasConnector extends Component { dispatchFetchRestrictions(); dispatchFetchImportLists(); dispatchFetchIndexers(); + dispatchFetchDownloadClients(); } // @@ -74,7 +77,8 @@ MetadatasConnector.propTypes = { dispatchFetchNotifications: PropTypes.func.isRequired, dispatchFetchRestrictions: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, - dispatchFetchIndexers: PropTypes.func.isRequired + dispatchFetchIndexers: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs index 0e6973152c..cb326ba7a5 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs @@ -34,7 +34,7 @@ public void SetUp() .Returns(_blockedProviders); } - private Mock WithUsenetClient(int priority = 0) + private Mock WithUsenetClient(int priority = 0, HashSet tags = null) { var mock = new Mock(MockBehavior.Default); mock.SetupGet(s => s.Definition) @@ -42,6 +42,7 @@ private Mock WithUsenetClient(int priority = 0) .CreateNew() .With(v => v.Id = _nextId++) .With(v => v.Priority = priority) + .With(v => v.Tags = tags ?? new HashSet()) .Build()); _downloadClients.Add(mock.Object); @@ -51,7 +52,7 @@ private Mock WithUsenetClient(int priority = 0) return mock; } - private Mock WithTorrentClient(int priority = 0) + private Mock WithTorrentClient(int priority = 0, HashSet tags = null) { var mock = new Mock(MockBehavior.Default); mock.SetupGet(s => s.Definition) @@ -59,6 +60,7 @@ private Mock WithTorrentClient(int priority = 0) .CreateNew() .With(v => v.Id = _nextId++) .With(v => v.Priority = priority) + .With(v => v.Tags = tags ?? new HashSet()) .Build()); _downloadClients.Add(mock.Object); @@ -148,6 +150,69 @@ public void should_roundrobin_over_protocol_separately() client4.Definition.Id.Should().Be(2); } + [Test] + public void should_roundrobin_over_clients_with_matching_tags() + { + var seriesTags = new HashSet { 1 }; + var clientTags = new HashSet { 1 }; + + WithTorrentClient(); + WithTorrentClient(0, clientTags); + WithTorrentClient(); + WithTorrentClient(0, clientTags); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + + client1.Definition.Id.Should().Be(2); + client2.Definition.Id.Should().Be(4); + client3.Definition.Id.Should().Be(2); + client4.Definition.Id.Should().Be(4); + } + + [Test] + public void should_roundrobin_over_non_tagged_when_no_matching_tags() + { + var seriesTags = new HashSet { 2 }; + var clientTags = new HashSet { 1 }; + + WithTorrentClient(); + WithTorrentClient(0, clientTags); + WithTorrentClient(); + WithTorrentClient(0, clientTags); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + + client1.Definition.Id.Should().Be(1); + client2.Definition.Id.Should().Be(3); + client3.Definition.Id.Should().Be(1); + client4.Definition.Id.Should().Be(3); + } + + [Test] + public void should_fail_to_choose_when_clients_have_tags_but_no_match() + { + var seriesTags = new HashSet { 2 }; + var clientTags = new HashSet { 1 }; + + WithTorrentClient(0, clientTags); + WithTorrentClient(0, clientTags); + WithTorrentClient(0, clientTags); + WithTorrentClient(0, clientTags); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + + Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags).Should().BeNull(); + } + [Test] public void should_skip_blocked_torrent_client() { @@ -162,7 +227,6 @@ public void should_skip_blocked_torrent_client() var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent); var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent); var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent); - var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent); client1.Definition.Id.Should().Be(2); client2.Definition.Id.Should().Be(4); diff --git a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs index d01bd9284d..e050659ccf 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(), It.IsAny(), It.IsAny())) - .Returns((v, i, f) => _downloadClients.FirstOrDefault(d => d.Protocol == v)); + .Setup(v => v.GetDownloadClient(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns>((v, i, f, t) => _downloadClients.FirstOrDefault(d => d.Protocol == v)); var releaseInfo = Builder.CreateNew() .With(v => v.DownloadProtocol = DownloadProtocol.Usenet) diff --git a/src/NzbDrone.Core/Datastore/Migration/226_add_download_client_tags.cs b/src/NzbDrone.Core/Datastore/Migration/226_add_download_client_tags.cs new file mode 100644 index 0000000000..ca549a45db --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/226_add_download_client_tags.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(226)] + public class add_download_client_tags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("DownloadClients").AddColumn("Tags").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 398c9aaae8..4f8254d3f6 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -104,8 +104,7 @@ public static void Map() Mapper.Entity("DownloadClients").RegisterModel() .Ignore(x => x.ImplementationName) - .Ignore(d => d.Protocol) - .Ignore(d => d.Tags); + .Ignore(d => d.Protocol); Mapper.Entity("History").RegisterModel(); diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 370b66dea7..8e8bcf8be7 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Indexers; @@ -9,7 +10,7 @@ namespace NzbDrone.Core.Download { public interface IProvideDownloadClient { - IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false); + IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false, HashSet tags = null); IEnumerable GetDownloadClients(bool filterBlockedClients = false); IDownloadClient Get(int id); } @@ -35,11 +36,20 @@ public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusS _lastUsedDownloadClient = cacheManager.GetCache(GetType(), "lastDownloadClientId"); } - public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false) + public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false, HashSet tags = null) { var blockedProviders = new HashSet(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId)); var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList(); + if (tags != null) + { + var matchingTagsClients = availableProviders.Where(i => i.Definition.Tags.Intersect(tags).Any()).ToList(); + + availableProviders = matchingTagsClients.Count > 0 ? + matchingTagsClients : + availableProviders.Where(i => i.Definition.Tags.Empty()).ToList(); + } + if (!availableProviders.Any()) { return null; diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 7a92885905..5eacaef10b 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -55,7 +55,8 @@ public void DownloadReport(RemoteMovie remoteMovie) var downloadTitle = remoteMovie.Release.Title; var filterBlockedClients = remoteMovie.Release.PendingReleaseReason == PendingReleaseReason.DownloadClientUnavailable; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteMovie.Release.DownloadProtocol, remoteMovie.Release.IndexerId, filterBlockedClients); + var tags = remoteMovie.Movie?.Tags; + var downloadClient = _downloadClientProvider.GetDownloadClient(remoteMovie.Release.DownloadProtocol, remoteMovie.Release.IndexerId, filterBlockedClients, tags); if (downloadClient == null) { diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs index f9798b47af..5d4aa12110 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -28,14 +28,14 @@ public class XbmcMetadata : MetadataBase private readonly IDetectXbmcNfo _detectNfo; private readonly IDiskProvider _diskProvider; private readonly ICreditService _creditService; - private readonly ITagService _tagService; + private readonly ITagRepository _tagRepository; private readonly IMovieTranslationService _movieTranslationsService; public XbmcMetadata(IDetectXbmcNfo detectNfo, IDiskProvider diskProvider, IMapCoversToLocal mediaCoverService, ICreditService creditService, - ITagService tagService, + ITagRepository tagRepository, IMovieTranslationService movieTranslationsService, Logger logger) { @@ -44,7 +44,7 @@ public XbmcMetadata(IDetectXbmcNfo detectNfo, _diskProvider = diskProvider; _detectNfo = detectNfo; _creditService = creditService; - _tagService = tagService; + _tagRepository = tagRepository; _movieTranslationsService = movieTranslationsService; } @@ -273,7 +273,7 @@ public override MetadataFileResult MovieMetadata(Movie movie, MovieFile movieFil details.Add(setElement); } - var tags = _tagService.GetTags(movie.Tags); + var tags = _tagRepository.Get(movie.Tags); foreach (var tag in tags) { diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 7465b1a45d..5b5ea37286 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -19,7 +19,7 @@ public CleanupUnusedTags(IMainDatabase database) public void Clean() { using var mapper = _database.OpenConnection(); - var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers" } + var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers", "DownloadClients" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToList(); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index eb5d6eba4a..c1ef0fc87f 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -273,6 +273,7 @@ "DownloadClientSortingCheckMessage": "Download client {0} has {1} sorting enabled for Radarr's category. You should disable sorting in your download client to avoid import issues.", "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", + "DownloadClientTagHelpText": "Only use this download client for movies with at least one matching tag. Leave blank to use with all movies.", "DownloadClientUnavailable": "Download client is unavailable", "DownloadClients": "Download Clients", "DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 57a66ff37b..23fc59e039 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -13,13 +13,14 @@ public class TagDetails : ModelBase public List ImportListIds { get; set; } public List DelayProfileIds { get; set; } public List IndexerIds { get; set; } + public List DownloadClientIds { get; set; } - public bool InUse - { - get - { - return MovieIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any(); - } - } + public bool InUse => MovieIds.Any() || + NotificationIds.Any() || + RestrictionIds.Any() || + DelayProfileIds.Any() || + ImportListIds.Any() || + IndexerIds.Any() || + DownloadClientIds.Any(); } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 9bc9a174ea..85f4d4ebf0 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Download; using NzbDrone.Core.ImportLists; using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Events; @@ -34,6 +35,7 @@ public class TagService : ITagService private readonly IRestrictionService _restrictionService; private readonly IMovieService _movieService; private readonly IIndexerFactory _indexerService; + private readonly IDownloadClientFactory _downloadClientFactory; public TagService(ITagRepository repo, IEventAggregator eventAggregator, @@ -42,7 +44,8 @@ public TagService(ITagRepository repo, INotificationFactory notificationFactory, IRestrictionService restrictionService, IMovieService movieService, - IIndexerFactory indexerService) + IIndexerFactory indexerService, + IDownloadClientFactory downloadClientFactory) { _repo = repo; _eventAggregator = eventAggregator; @@ -52,6 +55,7 @@ public TagService(ITagRepository repo, _restrictionService = restrictionService; _movieService = movieService; _indexerService = indexerService; + _downloadClientFactory = downloadClientFactory; } public Tag GetTag(int tagId) @@ -85,6 +89,7 @@ public TagDetails Details(int tagId) var restrictions = _restrictionService.AllForTag(tagId); var movies = _movieService.AllMovieTags().Where(x => x.Value.Contains(tagId)).Select(x => x.Key).ToList(); var indexers = _indexerService.AllForTag(tagId); + var downloadClients = _downloadClientFactory.AllForTag(tagId); return new TagDetails { @@ -95,7 +100,8 @@ public TagDetails Details(int tagId) NotificationIds = notifications.Select(c => c.Id).ToList(), RestrictionIds = restrictions.Select(c => c.Id).ToList(), MovieIds = movies, - IndexerIds = indexers.Select(c => c.Id).ToList() + IndexerIds = indexers.Select(c => c.Id).ToList(), + DownloadClientIds = downloadClients.Select(c => c.Id).ToList() }; } @@ -108,6 +114,7 @@ public List Details() var restrictions = _restrictionService.All(); var movies = _movieService.AllMovieTags(); var indexers = _indexerService.All(); + var downloadClients = _downloadClientFactory.All(); var details = new List(); @@ -122,7 +129,8 @@ public List Details() NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), MovieIds = movies.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(), - IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList() + IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + DownloadClientIds = downloadClients.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), }); } diff --git a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs index 7f61a34705..2bcf7929ee 100644 --- a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs @@ -14,6 +14,7 @@ public class TagDetailsResource : RestResource public List ImportListIds { get; set; } public List MovieIds { get; set; } public List IndexerIds { get; set; } + public List DownloadClientIds { get; set; } } public static class TagDetailsResourceMapper @@ -34,7 +35,8 @@ public static TagDetailsResource ToResource(this TagDetails model) RestrictionIds = model.RestrictionIds, ImportListIds = model.ImportListIds, MovieIds = model.MovieIds, - IndexerIds = model.IndexerIds + IndexerIds = model.IndexerIds, + DownloadClientIds = model.DownloadClientIds, }; }