New: Newznab/Torznab categories dropdown with indexer provided category names

This commit is contained in:
Taloth 2020-10-08 23:33:13 +02:00 committed by GitHub
parent 9dab2ba6e4
commit b4c27f5d34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 445 additions and 46 deletions

View File

@ -2,13 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchOptions, clearOptions } from 'Store/Actions/providerOptionActions'; import { fetchOptions, clearOptions, defaultState } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput'; import DeviceInput from './DeviceInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state, { value }) => value, (state, { value }) => value,
(state) => state.providerOptions, (state) => state.providerOptions.devices || defaultState,
(value, devices) => { (value, devices) => {
return { return {
@ -51,7 +51,7 @@ class DeviceInputConnector extends Component {
} }
componentWillUnmount = () => { componentWillUnmount = () => {
this.props.dispatchClearOptions(); this.props.dispatchClearOptions({ section: 'devices' });
} }
// //
@ -65,6 +65,7 @@ class DeviceInputConnector extends Component {
} = this.props; } = this.props;
dispatchFetchOptions({ dispatchFetchOptions({
section: 'devices',
action: 'getDevices', action: 'getDevices',
provider, provider,
providerData providerData

View File

@ -66,3 +66,8 @@
border-radius: 4px; border-radius: 4px;
background-color: $white; background-color: $white;
} }
.loading {
display: inline-block;
margin: 5px -5px 5px 0;
}

View File

@ -10,6 +10,7 @@ import { icons, sizes, scrollDirections } from 'Helpers/Props';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Portal from 'Components/Portal'; import Portal from 'Components/Portal';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure'; import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
@ -250,6 +251,10 @@ class EnhancedSelectInput extends Component {
this._addListener(); this._addListener();
} }
if (!this.state.isOpen) {
this.props.onOpen();
}
this.setState({ isOpen: !this.state.isOpen }); this.setState({ isOpen: !this.state.isOpen });
} }
@ -295,6 +300,7 @@ class EnhancedSelectInput extends Component {
value, value,
values, values,
isDisabled, isDisabled,
isFetching,
hasError, hasError,
hasWarning, hasWarning,
valueOptions, valueOptions,
@ -355,9 +361,21 @@ class EnhancedSelectInput extends Component {
styles.dropdownArrowContainer styles.dropdownArrowContainer
} }
> >
<Icon
name={icons.CARET_DOWN} {
/> isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</div> </div>
</Link> </Link>
</Measure> </Measure>
@ -483,12 +501,14 @@ EnhancedSelectInput.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasWarning: PropTypes.bool, hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired, valueOptions: PropTypes.object.isRequired,
selectedValueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired,
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
optionComponent: PropTypes.elementType, optionComponent: PropTypes.elementType,
onOpen: PropTypes.func,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };
@ -496,6 +516,7 @@ EnhancedSelectInput.defaultProps = {
className: styles.enhancedSelect, className: styles.enhancedSelect,
disabledClassName: styles.isDisabled, disabledClassName: styles.isDisabled,
isDisabled: false, isDisabled: false,
isFetching: false,
valueOptions: {}, valueOptions: {},
selectedValueOptions: {}, selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue, selectedValueComponent: HintedSelectInputSelectedValue,

View File

@ -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 { fetchOptions, clearOptions, defaultState } 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 (
<EnhancedSelectInput
{...this.props}
onOpen={this.onOpen}
/>
);
}
}
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);

View File

@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component {
const { const {
className, className,
id, id,
depth,
isSelected, isSelected,
isDisabled, isDisabled,
isHidden, isHidden,
@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component {
onPress={this.onPress} onPress={this.onPress}
> >
{
depth !== 0 &&
<div style={{ width: `${depth * 20}px` }} />
}
{ {
isMultiSelect && isMultiSelect &&
<CheckInput <CheckInput
@ -84,6 +90,7 @@ class EnhancedSelectInputOption extends Component {
EnhancedSelectInputOption.propTypes = { EnhancedSelectInputOption.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
depth: PropTypes.number.isRequired,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
@ -95,6 +102,7 @@ EnhancedSelectInputOption.propTypes = {
EnhancedSelectInputOption.defaultProps = { EnhancedSelectInputOption.defaultProps = {
className: styles.option, className: styles.option,
depth: 0,
isDisabled: false, isDisabled: false,
isHidden: false, isHidden: false,
isMultiSelect: false isMultiSelect: false

View File

@ -18,6 +18,7 @@ import IndexerSelectInputConnector from './IndexerSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector'; import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import SeriesTypeSelectInput from './SeriesTypeSelectInput'; import SeriesTypeSelectInput from './SeriesTypeSelectInput';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import TagInputConnector from './TagInputConnector'; import TagInputConnector from './TagInputConnector';
import TextTagInputConnector from './TextTagInputConnector'; import TextTagInputConnector from './TextTagInputConnector';
import TextInput from './TextInput'; import TextInput from './TextInput';
@ -71,6 +72,9 @@ function getComponent(type) {
case inputTypes.SELECT: case inputTypes.SELECT:
return EnhancedSelectInput; return EnhancedSelectInput;
case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector;
case inputTypes.SERIES_TYPE_SELECT: case inputTypes.SERIES_TYPE_SELECT:
return SeriesTypeSelectInput; return SeriesTypeSelectInput;

View File

@ -9,6 +9,7 @@ function HintedSelectInputOption(props) {
id, id,
value, value,
hint, hint,
depth,
isSelected, isSelected,
isDisabled, isDisabled,
isMultiSelect, isMultiSelect,
@ -19,6 +20,7 @@ function HintedSelectInputOption(props) {
return ( return (
<EnhancedSelectInputOption <EnhancedSelectInputOption
id={id} id={id}
depth={depth}
isSelected={isSelected} isSelected={isSelected}
isDisabled={isDisabled} isDisabled={isDisabled}
isHidden={isDisabled} isHidden={isDisabled}
@ -48,6 +50,7 @@ HintedSelectInputOption.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
hint: PropTypes.node, hint: PropTypes.node,
depth: PropTypes.number,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isMultiSelect: PropTypes.bool.isRequired, isMultiSelect: PropTypes.bool.isRequired,

View File

@ -6,7 +6,7 @@ import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
function getType(type, value) { function getType({ type, selectOptionsProviderAction }) {
switch (type) { switch (type) {
case 'captcha': case 'captcha':
return inputTypes.CAPTCHA; return inputTypes.CAPTCHA;
@ -23,6 +23,9 @@ function getType(type, value) {
case 'filePath': case 'filePath':
return inputTypes.PATH; return inputTypes.PATH;
case 'select': case 'select':
if (selectOptionsProviderAction) {
return inputTypes.DYNAMIC_SELECT;
}
return inputTypes.SELECT; return inputTypes.SELECT;
case 'tag': case 'tag':
return inputTypes.TEXT_TAG; return inputTypes.TEXT_TAG;
@ -85,7 +88,7 @@ function ProviderFieldFormGroup(props) {
<FormLabel>{label}</FormLabel> <FormLabel>{label}</FormLabel>
<FormInputGroup <FormInputGroup
type={getType(type, value)} type={getType(props)}
name={name} name={name}
label={label} label={label}
helpText={helpText} helpText={helpText}
@ -105,7 +108,8 @@ function ProviderFieldFormGroup(props) {
const selectOptionsShape = { const selectOptionsShape = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
value: PropTypes.number.isRequired value: PropTypes.number.isRequired,
hint: PropTypes.string
}; };
ProviderFieldFormGroup.propTypes = { ProviderFieldFormGroup.propTypes = {
@ -122,6 +126,7 @@ ProviderFieldFormGroup.propTypes = {
errors: PropTypes.arrayOf(PropTypes.object).isRequired, errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired, warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)), selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };

View File

@ -13,6 +13,7 @@ export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect';
export const INDEXER_SELECT = 'indexerSelect'; export const INDEXER_SELECT = 'indexerSelect';
export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
export const SELECT = 'select'; export const SELECT = 'select';
export const DYNAMIC_SELECT = 'dynamicSelect';
export const SERIES_TYPE_SELECT = 'seriesTypeSelect'; export const SERIES_TYPE_SELECT = 'seriesTypeSelect';
export const TAG = 'tag'; export const TAG = 'tag';
export const TEXT = 'text'; export const TEXT = 'text';
@ -34,6 +35,7 @@ export const all = [
INDEXER_SELECT, INDEXER_SELECT,
ROOT_FOLDER_SELECT, ROOT_FOLDER_SELECT,
SELECT, SELECT,
DYNAMIC_SELECT,
SERIES_TYPE_SELECT, SERIES_TYPE_SELECT,
TAG, TAG,
TEXT, TEXT,

View File

@ -1,3 +1,4 @@
import _ from 'lodash';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import requestAction from 'Utilities/requestAction'; import requestAction from 'Utilities/requestAction';
import updateSectionState from 'Utilities/State/updateSectionState'; import updateSectionState from 'Utilities/State/updateSectionState';
@ -10,6 +11,9 @@ import { set } from './baseActions';
export const section = 'providerOptions'; export const section = 'providerOptions';
const lastActions = {};
let lastActionId = 0;
// //
// State // State
@ -23,8 +27,8 @@ export const defaultState = {
// //
// Actions Types // Actions Types
export const FETCH_OPTIONS = 'devices/fetchOptions'; export const FETCH_OPTIONS = 'providers/fetchOptions';
export const CLEAR_OPTIONS = 'devices/clearOptions'; export const CLEAR_OPTIONS = 'providers/clearOptions';
// //
// Action Creators // Action Creators
@ -38,30 +42,55 @@ export const clearOptions = createAction(CLEAR_OPTIONS);
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
[FETCH_OPTIONS]: function(getState, payload, dispatch) { [FETCH_OPTIONS]: function(getState, payload, dispatch) {
const subsection = `${section}.${payload.section}`;
if (lastActions[payload.section] && _.isEqual(payload, lastActions[payload.section].payload)) {
return;
}
const actionId = ++lastActionId;
lastActions[payload.section] = {
actionId,
payload
};
dispatch(set({ dispatch(set({
section, section: subsection,
isFetching: true isFetching: true
})); }));
const promise = requestAction(payload); const promise = requestAction(payload);
promise.done((data) => { promise.done((data) => {
dispatch(set({ if (lastActions[payload.section]) {
section, if (lastActions[payload.section].actionId === actionId) {
isFetching: false, lastActions[payload.section] = null;
isPopulated: true, }
error: null,
items: data.options || [] dispatch(set({
})); section: subsection,
isFetching: false,
isPopulated: true,
error: null,
items: data.options || []
}));
}
}); });
promise.fail((xhr) => { promise.fail((xhr) => {
dispatch(set({ if (lastActions[payload.section]) {
section, if (lastActions[payload.section].actionId === actionId) {
isFetching: false, lastActions[payload.section] = null;
isPopulated: false, }
error: xhr
})); dispatch(set({
section: subsection,
isFetching: false,
isPopulated: false,
error: xhr
}));
}
}); });
} }
}); });
@ -71,8 +100,12 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({ export const reducers = createHandleActions({
[CLEAR_OPTIONS]: function(state) { [CLEAR_OPTIONS]: function(state, { payload }) {
return updateSectionState(state, section, defaultState); const subsection = `${section}.${payload.section}`;
lastActions[payload.section] = null;
return updateSectionState(state, subsection, defaultState);
} }
}, defaultState, section); }, {}, section);

View File

@ -19,6 +19,7 @@ namespace NzbDrone.Core.Annotations
public FieldType Type { get; set; } public FieldType Type { get; set; }
public bool Advanced { get; set; } public bool Advanced { get; set; }
public Type SelectOptions { get; set; } public Type SelectOptions { get; set; }
public string SelectOptionsProviderAction { get; set; }
public string Section { get; set; } public string Section { get; set; }
public HiddenType Hidden { get; set; } public HiddenType Hidden { get; set; }
public PrivacyLevel Privacy { get; set; } public PrivacyLevel Privacy { get; set; }
@ -38,6 +39,15 @@ namespace NzbDrone.Core.Annotations
public string Hint { get; set; } 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 public enum FieldType
{ {
Textbox, Textbox,

View File

@ -332,7 +332,14 @@ namespace NzbDrone.Core.Indexers
{ {
var parser = GetParser(); var parser = GetParser();
var generator = GetRequestGenerator(); 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()) if (releases.Empty())
{ {

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
@ -133,5 +134,31 @@ namespace NzbDrone.Core.Indexers.Newznab
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
} }
} }
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "newznabCategories")
{
List<NewznabCategory> 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);
}
} }
} }

View File

@ -8,6 +8,7 @@ using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Indexers.Newznab namespace NzbDrone.Core.Indexers.Newznab
{ {
@ -49,6 +50,7 @@ namespace NzbDrone.Core.Indexers.Newznab
} }
var request = new HttpRequest(url, HttpAccept.Rss); var request = new HttpRequest(url, HttpAccept.Rss);
request.AllowAutoRedirect = true;
HttpResponse response; HttpResponse response;

View File

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.UI.WebControls;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Indexers.Newznab
{
public static class NewznabCategoryFieldOptionsConverter
{
public static List<FieldSelectOption> GetFieldSelectOptions(List<NewznabCategory> categories)
{
// Ignore categories not relevant for Sonarr
var ignoreCategories = new[] { 0, 1000, 2000, 3000, 4000, 6000, 7000 };
var result = new List<FieldSelectOption>();
if (categories == null)
{
// Fetching categories failed, use default Newznab categories
categories = new List<NewznabCategory>();
categories.Add(new NewznabCategory
{
Id = 5000,
Name = "TV",
Subcategories = new List<NewznabCategory>
{
new NewznabCategory { Id = 5070, Name = "Anime" },
new NewznabCategory { Id = 5080, Name = "Documentary" },
new NewznabCategory { Id = 5020, Name = "Foreign" },
new NewznabCategory { Id = 5040, Name = "HD" },
new NewznabCategory { Id = 5045, Name = "UHD" },
new NewznabCategory { Id = 5050, Name = "Other" },
new NewznabCategory { Id = 5030, Name = "SD" },
new NewznabCategory { Id = 5060, Name = "Sport" },
new NewznabCategory { Id = 5010, Name = "WEB-DL" }
}
});
}
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;
}
}
}

View File

@ -73,10 +73,10 @@ namespace NzbDrone.Core.Indexers.Newznab
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)] [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[FieldDefinition(3, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows")] [FieldDefinition(3, Label = "Categories", Type = FieldType.Select, SelectOptionsProviderAction = "newznabCategories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows")]
public IEnumerable<int> Categories { get; set; } public IEnumerable<int> Categories { get; set; }
[FieldDefinition(4, Label = "Anime Categories", HelpText = "Comma Separated list, leave blank to disable anime")] [FieldDefinition(4, Label = "Anime Categories", Type = FieldType.Select, SelectOptionsProviderAction = "newznabCategories", HelpText = "Comma Separated list, leave blank to disable anime")]
public IEnumerable<int> AnimeCategories { get; set; } public IEnumerable<int> AnimeCategories { get; set; }
[FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)] [FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)]

View File

@ -55,17 +55,17 @@ namespace NzbDrone.Core.Indexers.Torznab
private IndexerDefinition GetDefinition(string name, TorznabSettings settings) private IndexerDefinition GetDefinition(string name, TorznabSettings settings)
{ {
return new IndexerDefinition return new IndexerDefinition
{ {
EnableRss = false, EnableRss = false,
EnableAutomaticSearch = false, EnableAutomaticSearch = false,
EnableInteractiveSearch = false, EnableInteractiveSearch = false,
Name = name, Name = name,
Implementation = GetType().Name, Implementation = GetType().Name,
Settings = settings, Settings = settings,
Protocol = DownloadProtocol.Usenet, Protocol = DownloadProtocol.Usenet,
SupportsRss = SupportsRss, SupportsRss = SupportsRss,
SupportsSearch = SupportsSearch SupportsSearch = SupportsSearch
}; };
} }
private TorznabSettings GetSettings(string url, string apiPath = null, int[] categories = null, int[] animeCategories = null) private TorznabSettings GetSettings(string url, string apiPath = null, int[] categories = null, int[] animeCategories = null)
@ -124,5 +124,28 @@ namespace NzbDrone.Core.Indexers.Torznab
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
} }
} }
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "newznabCategories")
{
List<NewznabCategory> categories = null;
try
{
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
}
catch
{
// Use default categories
}
return new
{
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
};
}
return base.RequestAction(action, query);
}
} }
} }

View File

@ -27,7 +27,7 @@ namespace Sonarr.Api.V3
Get("schema", x => GetTemplates()); Get("schema", x => GetTemplates());
Post("test", x => Test(ReadResourceFromRequest(true))); Post("test", x => Test(ReadResourceFromRequest(true)));
Post("testall", x => TestAll()); 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; GetResourceAll = GetAll;
GetResourceById = GetProviderById; GetResourceById = GetProviderById;

View File

@ -14,6 +14,7 @@ namespace Sonarr.Http.ClientSchema
public string Type { get; set; } public string Type { get; set; }
public bool Advanced { get; set; } public bool Advanced { get; set; }
public List<SelectOption> SelectOptions { get; set; } public List<SelectOption> SelectOptions { get; set; }
public string SelectOptionsProviderAction { get; set; }
public string Section { get; set; } public string Section { get; set; }
public string Hidden { get; set; } public string Hidden { get; set; }

View File

@ -106,7 +106,14 @@ namespace Sonarr.Http.ClientSchema
if (fieldAttribute.Type == FieldType.Select) 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) if (fieldAttribute.Hidden != HiddenType.Visible)

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentValidation; using FluentValidation;
using FluentValidation.Results;
using Nancy; using Nancy;
using Nancy.Responses.Negotiation; using Nancy.Responses.Negotiation;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -198,7 +199,7 @@ namespace Sonarr.Http.REST
return Negotiate.WithModel(model).WithStatusCode(statusCode); return Negotiate.WithModel(model).WithStatusCode(statusCode);
} }
protected TResource ReadResourceFromRequest(bool skipValidate = false) protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false)
{ {
TResource resource; TResource resource;
@ -216,7 +217,12 @@ namespace Sonarr.Http.REST
throw new BadRequestException("Request body can't be empty"); throw new BadRequestException("Request body can't be empty");
} }
var errors = SharedValidator.Validate(resource).Errors.ToList(); var errors = new List<ValidationFailure>();
if (!skipSharedValidate)
{
errors.AddRange(SharedValidator.Validate(resource).Errors);
}
if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase))
{ {