diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js index 8fa9d41c3..4e3281169 100644 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -2,13 +2,13 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { clearOptions, fetchOptions } from 'Store/Actions/providerOptionActions'; +import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; import DeviceInput from './DeviceInput'; function createMapStateToProps() { return createSelector( (state, { value }) => value, - (state) => state.providerOptions, + (state) => state.providerOptions.devices || defaultState, (value, devices) => { return { @@ -51,7 +51,7 @@ class DeviceInputConnector extends Component { } componentWillUnmount = () => { - this.props.dispatchClearOptions(); + this.props.dispatchClearOptions({ section: 'devices' }); } // @@ -65,6 +65,7 @@ class DeviceInputConnector extends Component { } = this.props; dispatchFetchOptions({ + section: 'devices', action: 'getDevices', provider, providerData diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index c3623c199..3d2df1ad3 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -66,3 +66,8 @@ border-radius: 4px; background-color: $white; } + +.loading { + display: inline-block; + margin: 5px -5px 5px 0; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 08690161d..bc9917caf 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -5,6 +5,7 @@ import React, { Component } from 'react'; import { Manager, Popper, Reference } from 'react-popper'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Measure from 'Components/Measure'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -250,6 +251,10 @@ class EnhancedSelectInput extends Component { this._addListener(); } + if (!this.state.isOpen && this.props.onOpen) { + this.props.onOpen(); + } + this.setState({ isOpen: !this.state.isOpen }); } @@ -295,6 +300,7 @@ class EnhancedSelectInput extends Component { value, values, isDisabled, + isFetching, hasError, hasWarning, valueOptions, @@ -355,9 +361,21 @@ class EnhancedSelectInput extends Component { styles.dropdownArrowContainer } > - + + { + isFetching && + + } + + { + !isFetching && + + } @@ -483,12 +501,14 @@ EnhancedSelectInput.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, hasError: PropTypes.bool, hasWarning: PropTypes.bool, valueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, optionComponent: PropTypes.elementType, + onOpen: PropTypes.func, onChange: PropTypes.func.isRequired }; @@ -496,6 +516,7 @@ EnhancedSelectInput.defaultProps = { className: styles.enhancedSelect, disabledClassName: styles.isDisabled, isDisabled: false, + isFetching: false, valueOptions: {}, selectedValueOptions: {}, selectedValueComponent: HintedSelectInputSelectedValue, diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js new file mode 100644 index 000000000..9323010ce --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputConnector.js @@ -0,0 +1,159 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +const importantFieldNames = [ + 'baseUrl', + 'apiPath', + 'apiKey' +]; + +function getProviderDataKey(providerData) { + if (!providerData || !providerData.fields) { + return null; + } + + const fields = providerData.fields + .filter((f) => importantFieldNames.includes(f.name)) + .map((f) => f.value); + + return fields; +} + +function getSelectOptions(items) { + if (!items) { + return []; + } + + return items.map((option) => { + return { + key: option.value, + value: option.name, + hint: option.hint, + parentKey: option.parentValue + }; + }); +} + +function createMapStateToProps() { + return createSelector( + (state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState, + (options) => { + if (options) { + return { + isFetching: options.isFetching, + values: getSelectOptions(options.items) + }; + } + } + ); +} + +const mapDispatchToProps = { + dispatchFetchOptions: fetchOptions, + dispatchClearOptions: clearOptions +}; + +class EnhancedSelectInputConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + refetchRequired: false + }; + } + + componentDidMount = () => { + this._populate(); + } + + componentDidUpdate = (prevProps) => { + const prevKey = getProviderDataKey(prevProps.providerData); + const nextKey = getProviderDataKey(this.props.providerData); + + if (!_.isEqual(prevKey, nextKey)) { + this.setState({ refetchRequired: true }); + } + } + + componentWillUnmount = () => { + this._cleanup(); + } + + // + // Listeners + + onOpen = () => { + if (this.state.refetchRequired) { + this._populate(); + } + } + + // + // Control + + _populate() { + const { + provider, + providerData, + selectOptionsProviderAction, + dispatchFetchOptions + } = this.props; + + if (selectOptionsProviderAction) { + this.setState({ refetchRequired: false }); + dispatchFetchOptions({ + section: selectOptionsProviderAction, + action: selectOptionsProviderAction, + provider, + providerData + }); + } + } + + _cleanup() { + const { + selectOptionsProviderAction, + dispatchClearOptions + } = this.props; + + if (selectOptionsProviderAction) { + dispatchClearOptions({ section: selectOptionsProviderAction }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +EnhancedSelectInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + selectOptionsProviderAction: PropTypes.string, + onChange: PropTypes.func.isRequired, + isFetching: PropTypes.bool.isRequired, + dispatchFetchOptions: PropTypes.func.isRequired, + dispatchClearOptions: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css index 7123f07a4..085b64497 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.css +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css @@ -13,12 +13,14 @@ .optionCheck { composes: container from '~./CheckInput.css'; + flex: 0 0 0; } .optionCheckInput { composes: input from '~./CheckInput.css'; - margin-top: 0px; + + margin-top: 0; } .isSelected { diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js index f594a4335..fce64d2f7 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component { const { className, id, + depth, isSelected, isDisabled, isHidden, @@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component { onPress={this.onPress} > + { + depth !== 0 && +
+ } + { isMultiSelect && {label} { - dispatch(set({ - section, - isFetching: false, - isPopulated: true, - error: null, - items: data.options || [] - })); + if (lastActions[payload.section]) { + if (lastActions[payload.section].actionId === actionId) { + lastActions[payload.section] = null; + } + + dispatch(set({ + section: subsection, + isFetching: false, + isPopulated: true, + error: null, + items: data.options || [] + })); + } }); promise.fail((xhr) => { - dispatch(set({ - section, - isFetching: false, - isPopulated: false, - error: xhr - })); + if (lastActions[payload.section]) { + if (lastActions[payload.section].actionId === actionId) { + lastActions[payload.section] = null; + } + + dispatch(set({ + section: subsection, + isFetching: false, + isPopulated: false, + error: xhr + })); + } }); } }); @@ -71,8 +100,12 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [CLEAR_OPTIONS]: function(state) { - return updateSectionState(state, section, defaultState); + [CLEAR_OPTIONS]: function(state, { payload }) { + const subsection = `${section}.${payload.section}`; + + lastActions[payload.section] = null; + + return updateSectionState(state, subsection, defaultState); } -}, defaultState, section); +}, {}, section); diff --git a/src/Lidarr.Api.V1/ProviderModuleBase.cs b/src/Lidarr.Api.V1/ProviderModuleBase.cs index 1e82fd7c6..bcbe99180 100644 --- a/src/Lidarr.Api.V1/ProviderModuleBase.cs +++ b/src/Lidarr.Api.V1/ProviderModuleBase.cs @@ -27,7 +27,7 @@ namespace Lidarr.Api.V1 Get("schema", x => GetTemplates()); Post("test", x => Test(ReadResourceFromRequest(true))); Post("testall", x => TestAll()); - Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true))); + Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true))); GetResourceAll = GetAll; GetResourceById = GetProviderById; diff --git a/src/Lidarr.Http/ClientSchema/Field.cs b/src/Lidarr.Http/ClientSchema/Field.cs index e9363c1f1..601aa8b3e 100644 --- a/src/Lidarr.Http/ClientSchema/Field.cs +++ b/src/Lidarr.Http/ClientSchema/Field.cs @@ -14,6 +14,7 @@ namespace Lidarr.Http.ClientSchema public string Type { get; set; } public bool Advanced { get; set; } public List SelectOptions { get; set; } + public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public string Hidden { get; set; } diff --git a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs index 4e135269f..592681f65 100644 --- a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs @@ -104,7 +104,14 @@ namespace Lidarr.Http.ClientSchema if (fieldAttribute.Type == FieldType.Select) { - field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); + if (fieldAttribute.SelectOptionsProviderAction.IsNotNullOrWhiteSpace()) + { + field.SelectOptionsProviderAction = fieldAttribute.SelectOptionsProviderAction; + } + else + { + field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); + } } if (fieldAttribute.Hidden != HiddenType.Visible) diff --git a/src/Lidarr.Http/REST/RestModule.cs b/src/Lidarr.Http/REST/RestModule.cs index 41584aaf3..af0545d85 100644 --- a/src/Lidarr.Http/REST/RestModule.cs +++ b/src/Lidarr.Http/REST/RestModule.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using FluentValidation; +using FluentValidation.Results; using Lidarr.Http.Extensions; using Nancy; using Nancy.Responses.Negotiation; @@ -224,7 +225,7 @@ namespace Lidarr.Http.REST return Negotiate.WithModel(model).WithStatusCode(statusCode); } - protected TResource ReadResourceFromRequest(bool skipValidate = false) + protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false) { var resource = new TResource(); @@ -242,7 +243,12 @@ namespace Lidarr.Http.REST throw new BadRequestException("Request body can't be empty"); } - var errors = SharedValidator.Validate(resource).Errors.ToList(); + var errors = new List(); + + if (!skipSharedValidate) + { + errors.AddRange(SharedValidator.Validate(resource).Errors); + } if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) { diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index f513465e8..c4da6f980 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Annotations public FieldType Type { get; set; } public bool Advanced { get; set; } public Type SelectOptions { get; set; } + public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public HiddenType Hidden { get; set; } public PrivacyLevel Privacy { get; set; } @@ -38,6 +39,15 @@ namespace NzbDrone.Core.Annotations public string Hint { get; set; } } + public class FieldSelectOption + { + public int Value { get; set; } + public string Name { get; set; } + public int Order { get; set; } + public string Hint { get; set; } + public int? ParentValue { get; set; } + } + public enum FieldType { Textbox, diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index 29ec6b6c6..8be8e9aad 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -294,7 +294,14 @@ namespace NzbDrone.Core.Indexers { var parser = GetParser(); var generator = GetRequestGenerator(); - var releases = FetchPage(generator.GetRecentRequests().GetAllTiers().First().First(), parser); + var firstRequest = generator.GetRecentRequests().GetAllTiers().FirstOrDefault()?.FirstOrDefault(); + + if (firstRequest == null) + { + return new ValidationFailure(string.Empty, "No rss feed query available. This may be an issue with the indexer or your indexer category settings."); + } + + var releases = FetchPage(firstRequest, parser); if (releases.Empty()) { diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index d8402f6a9..a0109a5fe 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -138,5 +138,31 @@ namespace NzbDrone.Core.Indexers.Newznab return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); } } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "newznabCategories") + { + List categories = null; + try + { + if (Settings.BaseUrl.IsNotNullOrWhiteSpace() && Settings.ApiPath.IsNotNullOrWhiteSpace()) + { + categories = _capabilitiesProvider.GetCapabilities(Settings).Categories; + } + } + catch + { + // Use default categories + } + + return new + { + options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories) + }; + } + + return base.RequestAction(action, query); + } } } diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs index 57bd8ea0a..46d9b14e4 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs @@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers.Newznab } var request = new HttpRequest(url, HttpAccept.Rss); + request.AllowAutoRedirect = true; HttpResponse response; diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs new file mode 100644 index 000000000..1e87f4550 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.Indexers.Newznab +{ + public static class NewznabCategoryFieldOptionsConverter + { + public static List GetFieldSelectOptions(List categories) + { + // Ignore categories not relevant for Lidarr + var ignoreCategories = new[] { 0, 1000, 2000, 4000, 5000, 6000, 7000 }; + + var result = new List(); + + if (categories == null) + { + // Fetching categories failed, use default Newznab categories + categories = new List(); + categories.Add(new NewznabCategory + { + Id = 3000, + Name = "Music", + Subcategories = new List + { + new NewznabCategory { Id = 3040, Name = "Loseless" }, + new NewznabCategory { Id = 3010, Name = "MP3" }, + new NewznabCategory { Id = 3050, Name = "Other" }, + new NewznabCategory { Id = 3030, Name = "Audiobook" } + } + }); + } + + foreach (var category in categories) + { + if (ignoreCategories.Contains(category.Id)) + { + continue; + } + + result.Add(new FieldSelectOption + { + Value = category.Id, + Name = category.Name, + Hint = $"({category.Id})" + }); + + if (category.Subcategories != null) + { + foreach (var subcat in category.Subcategories) + { + result.Add(new FieldSelectOption + { + Value = subcat.Id, + Name = subcat.Name, + Hint = $"({subcat.Id})", + ParentValue = category.Id + }); + } + } + } + + result.Sort((l, r) => l.Value.CompareTo(r.Value)); + + return result; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs index 514b3010c..11c64f361 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Indexers.Newznab public NewznabSettings() { ApiPath = "/api"; - Categories = new[] { 3000, 3010, 3020, 3030, 3040 }; + Categories = new[] { 3000, 3010, 3030, 3040 }; } [FieldDefinition(0, Label = "URL")] @@ -70,7 +70,7 @@ namespace NzbDrone.Core.Indexers.Newznab [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows", Advanced = true)] + [FieldDefinition(3, Label = "Categories", Type = FieldType.Select, SelectOptionsProviderAction = "newznabCategories", HelpText = "Comma Separated list")] public IEnumerable Categories { get; set; } [FieldDefinition(4, Type = FieldType.Number, Label = "Early Download Limit", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Unit = "days", Advanced = true)] diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs index 27132cd85..027390ed3 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs @@ -106,5 +106,28 @@ namespace NzbDrone.Core.Indexers.Torznab return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); } } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "newznabCategories") + { + List categories = null; + try + { + categories = _capabilitiesProvider.GetCapabilities(Settings).Categories; + } + catch + { + // Use default categories + } + + return new + { + options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories) + }; + } + + return base.RequestAction(action, query); + } } }