diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index 470b84120..aac13e5c2 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -43,6 +43,7 @@ function EditIndexerModalContent(props) { enableInteractiveSearch, supportsRss, supportsSearch, + tags, fields, priority } = item; @@ -135,6 +136,7 @@ function EditIndexerModalContent(props) { ); }) } + + + + Tags + + + } diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js index 376c443a4..063abd989 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexer.js +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js @@ -4,6 +4,7 @@ import Card from 'Components/Card'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagList from 'Components/TagList'; import { icons, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import EditIndexerModalConnector from './EditIndexerModalConnector'; @@ -68,6 +69,8 @@ class Indexer extends Component { enableRss, enableAutomaticSearch, enableInteractiveSearch, + tags, + tagList, supportsRss, supportsSearch, priority, @@ -133,6 +136,11 @@ class Indexer extends Component { } + + indexers + createTagsSelector(), + (indexers, tagList) => { + return { + ...indexers, + tagList + }; + } ); } diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 96b4f1ce2..d92e5ce4d 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -22,6 +22,7 @@ function TagDetailsModalContent(props) { notifications, restrictions, importLists, + indexers, onModalClose, onDeleteTagPress } = props; @@ -41,7 +42,7 @@ function TagDetailsModalContent(props) { } { - !!movies.length && + movies.length ?
{ movies.map((item) => { @@ -52,11 +53,12 @@ function TagDetailsModalContent(props) { ); }) } -
+ : + null } { - !!delayProfiles.length && + delayProfiles.length ?
{ delayProfiles.map((item) => { @@ -81,11 +83,12 @@ function TagDetailsModalContent(props) { ); }) } -
+ : + null } { - !!notifications.length && + notifications.length ?
{ notifications.map((item) => { @@ -96,11 +99,12 @@ function TagDetailsModalContent(props) { ); }) } -
+ : + null } { - !!restrictions.length && + restrictions.length ?
{ restrictions.map((item) => { @@ -142,7 +146,24 @@ function TagDetailsModalContent(props) { ); }) } -
+ : + null + } + + { + indexers.length ? +
+ { + indexers.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
: + null } { @@ -192,6 +213,7 @@ TagDetailsModalContent.propTypes = { notifications: PropTypes.arrayOf(PropTypes.object).isRequired, restrictions: PropTypes.arrayOf(PropTypes.object).isRequired, importLists: PropTypes.arrayOf(PropTypes.object).isRequired, + indexers: 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 defb88635..0aa51a71c 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -69,6 +69,14 @@ function createMatchingImportListsSelector() { ); } +function createMatchingIndexersSelector() { + return createSelector( + (state, { indexerIds }) => indexerIds, + (state) => state.settings.indexers.items, + findMatchingItems + ); +} + function createMapStateToProps() { return createSelector( createMatchingMoviesSelector(), @@ -76,13 +84,15 @@ function createMapStateToProps() { createMatchingNotificationsSelector(), createMatchingRestrictionsSelector(), createMatchingImportListsSelector(), - (movies, delayProfiles, notifications, restrictions, importLists) => { + createMatchingIndexersSelector(), + (movies, delayProfiles, notifications, restrictions, importLists, indexers) => { return { movies, delayProfiles, notifications, restrictions, - importLists + importLists, + indexers }; } ); diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 5d4571ebd..d14c3b1bc 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -57,7 +57,8 @@ class Tag extends Component { notificationIds, restrictionIds, importListIds, - movieIds + movieIds, + indexerIds } = this.props; const { @@ -70,7 +71,8 @@ class Tag extends Component { notificationIds.length || restrictionIds.length || importListIds.length || - movieIds.length + movieIds.length || + indexerIds.length ); return ( @@ -120,6 +122,14 @@ class Tag extends Component { {importListIds.length} list{importListIds.length > 1 && 's'} } + + { + indexerIds.length ? +
+ {indexerIds.length} indexer{indexerIds.length > 1 && 's'} +
: + null + } } @@ -138,6 +148,7 @@ class Tag extends Component { notificationIds={notificationIds} restrictionIds={restrictionIds} importListIds={importListIds} + indexerIds={indexerIds} isOpen={isDetailsModalOpen} onModalClose={this.onDetailsModalClose} onDeleteTagPress={this.onDeleteTagPress} @@ -165,6 +176,7 @@ Tag.propTypes = { restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, importListIds: PropTypes.arrayOf(PropTypes.number).isRequired, movieIds: PropTypes.arrayOf(PropTypes.number).isRequired, + indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired, onConfirmDeleteTag: PropTypes.func.isRequired }; @@ -173,7 +185,8 @@ Tag.defaultProps = { notificationIds: [], restrictionIds: [], importListIds: [], - movieIds: [] + movieIds: [], + indexerIds: [] }; export default Tag; diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 88ffcd776..a3ed6b8c6 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, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions'; +import { fetchDelayProfiles, fetchImportLists, fetchIndexers, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions'; import { fetchTagDetails } from 'Store/Actions/tagActions'; import Tags from './Tags'; @@ -29,7 +29,8 @@ const mapDispatchToProps = { dispatchFetchDelayProfiles: fetchDelayProfiles, dispatchFetchNotifications: fetchNotifications, dispatchFetchRestrictions: fetchRestrictions, - dispatchFetchImportLists: fetchImportLists + dispatchFetchImportLists: fetchImportLists, + dispatchFetchIndexers: fetchIndexers }; class MetadatasConnector extends Component { @@ -43,7 +44,8 @@ class MetadatasConnector extends Component { dispatchFetchDelayProfiles, dispatchFetchNotifications, dispatchFetchRestrictions, - dispatchFetchImportLists + dispatchFetchImportLists, + dispatchFetchIndexers } = this.props; dispatchFetchTagDetails(); @@ -51,6 +53,7 @@ class MetadatasConnector extends Component { dispatchFetchNotifications(); dispatchFetchRestrictions(); dispatchFetchImportLists(); + dispatchFetchIndexers(); } // @@ -70,7 +73,8 @@ MetadatasConnector.propTypes = { dispatchFetchDelayProfiles: PropTypes.func.isRequired, dispatchFetchNotifications: PropTypes.func.isRequired, dispatchFetchRestrictions: PropTypes.func.isRequired, - dispatchFetchImportLists: PropTypes.func.isRequired + dispatchFetchImportLists: PropTypes.func.isRequired, + dispatchFetchIndexers: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/IndexerTagSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/IndexerTagSpecificationFixture.cs new file mode 100644 index 000000000..7ce13fde0 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/IndexerTagSpecificationFixture.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine.Specifications.RssSync; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync +{ + [TestFixture] + public class IndexerTagSpecificationFixture : CoreTest + { + private IndexerTagSpecification _specification; + + private RemoteMovie _parseResultMulti; + private IndexerDefinition _fakeIndexerDefinition; + private Movie _fakeMovie; + private ReleaseInfo _fakeRelease; + + [SetUp] + public void Setup() + { + _fakeIndexerDefinition = new IndexerDefinition + { + Tags = new HashSet() + }; + + Mocker + .GetMock() + .Setup(m => m.Get(It.IsAny())) + .Returns(_fakeIndexerDefinition); + + _specification = Mocker.Resolve(); + + _fakeMovie = Builder.CreateNew() + .With(c => c.Monitored = true) + .With(c => c.Tags = new HashSet()) + .Build(); + + _fakeRelease = new ReleaseInfo + { + IndexerId = 1 + }; + + _parseResultMulti = new RemoteMovie + { + Movie = _fakeMovie, + Release = _fakeRelease + }; + } + + [Test] + public void indexer_and_movie_without_tags_should_return_true() + { + _fakeIndexerDefinition.Tags = new HashSet(); + _fakeMovie.Tags = new HashSet(); + + _specification.IsSatisfiedBy(_parseResultMulti, new MovieSearchCriteria()).Accepted.Should().BeTrue(); + } + + [Test] + public void indexer_with_tags_movie_without_tags_should_return_false() + { + _fakeIndexerDefinition.Tags = new HashSet { 123 }; + _fakeMovie.Tags = new HashSet(); + + _specification.IsSatisfiedBy(_parseResultMulti, new MovieSearchCriteria()).Accepted.Should().BeFalse(); + } + + [Test] + public void indexer_without_tags_movie_with_tags_should_return_true() + { + _fakeIndexerDefinition.Tags = new HashSet(); + _fakeMovie.Tags = new HashSet { 123 }; + + _specification.IsSatisfiedBy(_parseResultMulti, new MovieSearchCriteria()).Accepted.Should().BeTrue(); + } + + [Test] + public void indexer_with_tags_movie_with_matching_tags_should_return_true() + { + _fakeIndexerDefinition.Tags = new HashSet { 123, 456 }; + _fakeMovie.Tags = new HashSet { 123, 789 }; + + _specification.IsSatisfiedBy(_parseResultMulti, new MovieSearchCriteria()).Accepted.Should().BeTrue(); + } + + [Test] + public void indexer_with_tags_movie_with_different_tags_should_return_false() + { + _fakeIndexerDefinition.Tags = new HashSet { 456 }; + _fakeMovie.Tags = new HashSet { 123, 789 }; + + _specification.IsSatisfiedBy(_parseResultMulti, new MovieSearchCriteria()).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/198_add_indexer_tags.cs b/src/NzbDrone.Core/Datastore/Migration/198_add_indexer_tags.cs new file mode 100644 index 000000000..a22563ee9 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/198_add_indexer_tags.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(198)] + public class add_indexer_tags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Indexers").AddColumn("Tags").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index c8caccd23..097cc2442 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -73,8 +73,7 @@ namespace NzbDrone.Core.Datastore .Ignore(i => i.Enable) .Ignore(i => i.Protocol) .Ignore(i => i.SupportsRss) - .Ignore(i => i.SupportsSearch) - .Ignore(d => d.Tags); + .Ignore(i => i.SupportsSearch); Mapper.Entity("ImportLists").RegisterModel() .Ignore(x => x.ImplementationName) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/IndexerTagSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/IndexerTagSpecification.cs new file mode 100644 index 000000000..cb5d6b508 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/IndexerTagSpecification.cs @@ -0,0 +1,39 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync +{ + public class IndexerTagSpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + private readonly IIndexerRepository _indexerRepository; + + public IndexerTagSpecification(Logger logger, IIndexerRepository indexerRepository) + { + _logger = logger; + _indexerRepository = indexerRepository; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public virtual Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase searchCriteria) + { + // If indexer has tags, check that at least one of them is present on the series + var indexerTags = _indexerRepository.Get(subject.Release.IndexerId).Tags; + + if (indexerTags.Any() && indexerTags.Intersect(subject.Movie.Tags).Empty()) + { + _logger.Debug("Indexer {0} has tags. None of these are present on movie {1}. Rejecting", subject.Release.Indexer, subject.Movie); + + return Decision.Reject("Movie tags do not match any of the indexer tags"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index fbf958f5f..f4ed2715e 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -102,6 +102,9 @@ namespace NzbDrone.Core.IndexerSearch _indexerFactory.InteractiveSearchEnabled() : _indexerFactory.AutomaticSearchEnabled(); + // Filter indexers to untagged indexers and indexers with intersecting tags + indexers = indexers.Where(i => i.Definition.Tags.Empty() || i.Definition.Tags.Intersect(criteriaBase.Movie.Tags).Any()).ToList(); + var reports = new List(); _logger.ProgressInfo("Searching indexers for {0}. {1} active indexers", criteriaBase, indexers.Count); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 3b4b233c7..11ca6fa2f 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -433,6 +433,7 @@ "IndexersSettingsSummary": "Indexers and release restrictions", "IndexerStatusCheckAllClientMessage": "All indexers are unavailable due to failures", "IndexerStatusCheckSingleClientMessage": "Indexers unavailable due to failures: {0}", + "IndexerTagHelpText": "Only use this indexer for series with at least one matching tag. Leave blank to use with all movies.", "Info": "Info", "InstallLatest": "Install Latest", "InteractiveImport": "Interactive Import", diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 16c262176..57a66ff37 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -12,12 +12,13 @@ namespace NzbDrone.Core.Tags public List RestrictionIds { get; set; } public List ImportListIds { get; set; } public List DelayProfileIds { get; set; } + public List IndexerIds { get; set; } public bool InUse { get { - return MovieIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any(); + return MovieIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any(); } } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index a6569c87e..9bc9a174e 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Datastore; using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Movies; using NzbDrone.Core.Notifications; @@ -32,6 +33,7 @@ namespace NzbDrone.Core.Tags private readonly INotificationFactory _notificationFactory; private readonly IRestrictionService _restrictionService; private readonly IMovieService _movieService; + private readonly IIndexerFactory _indexerService; public TagService(ITagRepository repo, IEventAggregator eventAggregator, @@ -39,7 +41,8 @@ namespace NzbDrone.Core.Tags IImportListFactory importListFactory, INotificationFactory notificationFactory, IRestrictionService restrictionService, - IMovieService movieService) + IMovieService movieService, + IIndexerFactory indexerService) { _repo = repo; _eventAggregator = eventAggregator; @@ -48,6 +51,7 @@ namespace NzbDrone.Core.Tags _notificationFactory = notificationFactory; _restrictionService = restrictionService; _movieService = movieService; + _indexerService = indexerService; } public Tag GetTag(int tagId) @@ -80,6 +84,7 @@ namespace NzbDrone.Core.Tags var notifications = _notificationFactory.AllForTag(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); return new TagDetails { @@ -89,7 +94,8 @@ namespace NzbDrone.Core.Tags ImportListIds = importLists.Select(c => c.Id).ToList(), NotificationIds = notifications.Select(c => c.Id).ToList(), RestrictionIds = restrictions.Select(c => c.Id).ToList(), - MovieIds = movies + MovieIds = movies, + IndexerIds = indexers.Select(c => c.Id).ToList() }; } @@ -101,6 +107,7 @@ namespace NzbDrone.Core.Tags var notifications = _notificationFactory.All(); var restrictions = _restrictionService.All(); var movies = _movieService.AllMovieTags(); + var indexers = _indexerService.All(); var details = new List(); @@ -114,7 +121,8 @@ namespace NzbDrone.Core.Tags ImportListIds = importLists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), 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() + 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() }); } diff --git a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs index 1d9dfbf19..7f61a3470 100644 --- a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs @@ -13,6 +13,7 @@ namespace Radarr.Api.V3.Tags public List RestrictionIds { get; set; } public List ImportListIds { get; set; } public List MovieIds { get; set; } + public List IndexerIds { get; set; } } public static class TagDetailsResourceMapper @@ -32,7 +33,8 @@ namespace Radarr.Api.V3.Tags NotificationIds = model.NotificationIds, RestrictionIds = model.RestrictionIds, ImportListIds = model.ImportListIds, - MovieIds = model.MovieIds + MovieIds = model.MovieIds, + IndexerIds = model.IndexerIds }; }