diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css new file mode 100644 index 000000000..0c518e98e --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.css @@ -0,0 +1,8 @@ +.deviceInputWrapper { + display: flex; +} + +.inputContainer { + composes: inputContainer from './TagInput.css'; + composes: hasButton from 'Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js new file mode 100644 index 000000000..a38648e1a --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TagInput, { tagShape } from './TagInput'; +import styles from './DeviceInput.css'; + +class DeviceInput extends Component { + + onTagAdd = (device) => { + const { + name, + value, + onChange + } = this.props; + + // New tags won't have an ID, only a name. + const deviceId = device.id || device.name; + + onChange({ + name, + value: [...value, deviceId] + }); + } + + onTagDelete = ({ index }) => { + const { + name, + value, + onChange + } = this.props; + + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ + name, + value: newValue + }); + } + + // + // Render + + render() { + const { + className, + items, + selectedDevices, + hasError, + hasWarning, + isFetching, + onRefreshPress + } = this.props; + + return ( +
+ + + + + +
+ ); + } +} + +DeviceInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired +}; + +DeviceInput.defaultProps = { + className: styles.deviceInputWrapper, + inputClassName: styles.input +}; + +export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js new file mode 100644 index 000000000..d53372b35 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions'; +import DeviceInput from './DeviceInput'; + +function createMapStateToProps() { + return createSelector( + (state, { value }) => value, + (state) => state.devices, + (value, devices) => { + + return { + ...devices, + selectedDevices: value.map((valueDevice) => { + // Disable equality ESLint rule so we don't need to worry about + // a type mismatch between the value items and the device ID. + // eslint-disable-next-line eqeqeq + const device = devices.items.find((d) => d.id == valueDevice); + + if (device) { + return { + id: device.id, + name: `${device.name} (${device.id})` + }; + } + + return { + id: valueDevice, + name: `Unknown (${valueDevice})` + }; + }) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDevices: fetchDevices, + dispatchClearDevices: clearDevices +}; + +class DeviceInputConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + this._populate(); + } + + componentWillUnmount = () => { + // this.props.dispatchClearDevices(); + } + + // + // Control + + _populate() { + const { + provider, + providerData, + dispatchFetchDevices + } = this.props; + + dispatchFetchDevices({ provider, providerData }); + } + + // + // Listeners + + onRefreshPress = () => { + this._populate(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeviceInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchDevices: PropTypes.func.isRequired, + dispatchClearDevices: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index c9348acdb..2a38be6b1 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -4,6 +4,7 @@ import { inputTypes } from 'Helpers/Props'; import Link from 'Components/Link/Link'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; +import DeviceInputConnector from './DeviceInputConnector'; import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput'; import NumberInput from './NumberInput'; import OAuthInputConnector from './OAuthInputConnector'; @@ -30,6 +31,9 @@ function getComponent(type) { case inputTypes.CHECK: return CheckInput; + case inputTypes.DEVICE: + return DeviceInputConnector; + case inputTypes.MONITOR_ALBUMS_SELECT: return MonitorAlbumsSelectInput; diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index a041a7f65..98922dae4 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -12,6 +12,8 @@ function getType(type) { return inputTypes.CAPTCHA; case 'checkbox': return inputTypes.CHECK; + case 'device': + return inputTypes.DEVICE; case 'password': return inputTypes.PASSWORD; case 'number': @@ -20,6 +22,8 @@ function getType(type) { return inputTypes.PATH; case 'select': return inputTypes.SELECT; + case 'tag': + return inputTypes.TEXT_TAG; case 'textbox': return inputTypes.TEXT; case 'oauth': diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js index 8c977e58d..7a26bf2f8 100644 --- a/frontend/src/Components/Form/TagInput.js +++ b/frontend/src/Components/Form/TagInput.js @@ -54,7 +54,7 @@ class TagInput extends Component { return value.length >= this.props.minQueryLength; } - renderSuggestion({ name }, { query }) { + renderSuggestion({ name }) { return name; } @@ -202,6 +202,8 @@ class TagInput extends Component { render() { const { + className, + inputClassName, placeholder, hasError, hasWarning @@ -214,7 +216,7 @@ class TagInput extends Component { } = this.state; const inputProps = { - className: styles.input, + className: inputClassName, name, value, placeholder, @@ -228,7 +230,7 @@ class TagInput extends Component { const theme = { container: classNames( - styles.inputContainer, + className, isFocused && styles.isFocused, hasError && styles.hasError, hasWarning && styles.hasWarning, @@ -266,6 +268,8 @@ export const tagShape = { }; TagInput.propTypes = { + className: PropTypes.string.isRequired, + inputClassName: PropTypes.string.isRequired, tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, allowNew: PropTypes.bool.isRequired, @@ -281,6 +285,8 @@ TagInput.propTypes = { }; TagInput.defaultProps = { + className: styles.inputContainer, + inputClassName: styles.input, allowNew: true, kind: kinds.INFO, placeholder: '', diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js index 03d593b75..1019dff0b 100644 --- a/frontend/src/Components/Form/TextTagInputConnector.js +++ b/frontend/src/Components/Form/TextTagInputConnector.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import isString from 'Utilities/String/isString'; import split from 'Utilities/String/split'; import TagInput from './TagInput'; @@ -10,8 +11,11 @@ function createMapStateToProps() { return createSelector( (state, { value }) => value, (tags) => { + const isArray = !isString(tags); + const tagsArray = isArray ? tags :split(tags); + return { - tags: split(tags).reduce((result, tag) => { + tags: tagsArray.reduce((result, tag) => { if (tag) { result.push({ id: tag, @@ -20,7 +24,8 @@ function createMapStateToProps() { } return result; - }, []) + }, []), + isArray }; } ); @@ -35,10 +40,11 @@ class TextTagInputConnector extends Component { const { name, value, + isArray, onChange } = this.props; - const newValue = split(value); + const newValue = isArray ? [...value] : split(value); newValue.push(tag.name); onChange({ name, value: newValue.join(',') }); @@ -48,10 +54,11 @@ class TextTagInputConnector extends Component { const { name, value, + isArray, onChange } = this.props; - const newValue = split(value); + const newValue = isArray ? [...value] : split(value); newValue.splice(index, 1); onChange({ @@ -77,7 +84,8 @@ class TextTagInputConnector extends Component { TextTagInputConnector.propTypes = { name: PropTypes.string.isRequired, - value: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + isArray: PropTypes.bool.isRequired, onChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index e950ce459..9832b7072 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -1,5 +1,6 @@ export const CAPTCHA = 'captcha'; export const CHECK = 'check'; +export const DEVICE = 'device'; export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect'; export const NUMBER = 'number'; export const OAUTH = 'oauth'; @@ -19,6 +20,7 @@ export const TEXT_TAG = 'textTag'; export const all = [ CAPTCHA, CHECK, + DEVICE, MONITOR_ALBUMS_SELECT, NUMBER, OAUTH, diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/deviceActions.js new file mode 100644 index 000000000..089d49bf3 --- /dev/null +++ b/frontend/src/Store/Actions/deviceActions.js @@ -0,0 +1,83 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'devices'; + +// +// State + +export const defaultState = { + items: [], + isFetching: false, + isPopulated: false, + error: false +}; + +// +// Actions Types + +export const FETCH_DEVICES = 'devices/fetchDevices'; +export const CLEAR_DEVICES = 'devices/clearDevices'; + +// +// Action Creators + +export const fetchDevices = createThunk(FETCH_DEVICES); +export const clearDevices = createAction(CLEAR_DEVICES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_DEVICES]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'getDevices', + ...payload + }; + + dispatch(set({ + section, + isFetching: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null, + items: data.devices || [] + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_DEVICES]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 25756242a..9f8e162bd 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -2,6 +2,7 @@ import * as addArtist from './addArtistActions'; import * as app from './appActions'; import * as blacklist from './blacklistActions'; import * as captcha from './captchaActions'; +import * as devices from './deviceActions'; import * as calendar from './calendarActions'; import * as commands from './commandActions'; import * as albums from './albumActions'; @@ -34,6 +35,7 @@ export default [ captcha, calendar, commands, + devices, albums, trackFiles, albumHistory, diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 41238d2b3..18d015bae 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -34,6 +34,7 @@ namespace NzbDrone.Core.Annotations Action, Url, Captcha, - OAuth + OAuth, + Device } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs index 9b5ca8d27..feaae84fd 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs @@ -1,6 +1,9 @@ +using System; using System.Collections.Generic; +using System.Linq; using FluentValidation.Results; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Notifications.PushBullet { @@ -16,7 +19,6 @@ namespace NzbDrone.Core.Notifications.PushBullet public override string Name => "Pushbullet"; public override string Link => "https://www.pushbullet.com/"; - public override void OnGrab(GrabMessage grabMessage) { _proxy.SendNotification(ALBUM_GRABBED_TITLE_BRANDED, grabMessage.Message, Settings); @@ -40,5 +42,36 @@ namespace NzbDrone.Core.Notifications.PushBullet return new ValidationResult(failures); } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "getDevices") + { + // Return early if there is not an API key + if (Settings.ApiKey.IsNullOrWhiteSpace()) + { + return new + { + devices = new List() + }; + } + + Settings.Validate().Filter("ApiKey").ThrowOnError(); + var devices = _proxy.GetDevices(Settings); + + return new + { + devices = devices.Where(d => d.Nickname.IsNotNullOrWhiteSpace()) + .OrderBy(d => d.Nickname, StringComparer.InvariantCultureIgnoreCase) + .Select(d => new + { + id = d.Id, + name = d.Nickname + }) + }; + } + + return new { }; + } } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevice.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevice.cs new file mode 100644 index 000000000..5d88e906e --- /dev/null +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevice.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.PushBullet +{ + public class PushBulletDevice + { + [JsonProperty(PropertyName = "Iden")] + public string Id { get; set; } + + public string Nickname { get; set; } + public string Manufacturer { get; set; } + public string Model { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevicesResponse.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevicesResponse.cs new file mode 100644 index 000000000..ae50f82b0 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevicesResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.PushBullet +{ + public class PushBulletDevicesResponse + { + public List Devices { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs index 35d97f5fa..8bc8a04a9 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using FluentValidation.Results; @@ -6,6 +7,7 @@ using NLog; using RestSharp; using NzbDrone.Core.Rest; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; using RestSharp.Authenticators; namespace NzbDrone.Core.Notifications.PushBullet @@ -13,13 +15,15 @@ namespace NzbDrone.Core.Notifications.PushBullet public interface IPushBulletProxy { void SendNotification(string title, string message, PushBulletSettings settings); + List GetDevices(PushBulletSettings settings); ValidationFailure Test(PushBulletSettings settings); } public class PushBulletProxy : IPushBulletProxy { private readonly Logger _logger; - private const string URL = "https://api.pushbullet.com/v2/pushes"; + private const string PUSH_URL = "https://api.pushbullet.com/v2/pushes"; + private const string DEVICE_URL = "https://api.pushbullet.com/v2/devices"; public PushBulletProxy(Logger logger) { @@ -88,6 +92,30 @@ namespace NzbDrone.Core.Notifications.PushBullet } } + public List GetDevices(PushBulletSettings settings) + { + try + { + var client = RestClientFactory.BuildClient(DEVICE_URL); + var request = new RestRequest(Method.GET); + + client.Authenticator = new HttpBasicAuthenticator(settings.ApiKey, string.Empty); + var response = client.ExecuteAndValidate(request); + + return Json.Deserialize(response.Content).Devices; + } + catch (RestException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, "Access token is invalid"); + throw; + } + } + + return new List(); + } + public ValidationFailure Test(PushBulletSettings settings) { try @@ -147,7 +175,7 @@ namespace NzbDrone.Core.Notifications.PushBullet { try { - var client = RestClientFactory.BuildClient(URL); + var client = RestClientFactory.BuildClient(PUSH_URL); request.AddParameter("type", "note"); request.AddParameter("title", title); @@ -165,7 +193,7 @@ namespace NzbDrone.Core.Notifications.PushBullet { if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) { - _logger.Error(ex, "API Key is invalid"); + _logger.Error(ex, "Access token is invalid"); throw; } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs index d2c6d1e4c..4e8ee404f 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; @@ -20,14 +20,14 @@ namespace NzbDrone.Core.Notifications.PushBullet public PushBulletSettings() { - DeviceIds = new string[]{}; - ChannelTags = new string[]{}; + DeviceIds = new string[] { }; + ChannelTags = new string[] { }; } - [FieldDefinition(0, Label = "API Key", HelpLink = "https://www.pushbullet.com/")] + [FieldDefinition(0, Label = "Access Token", HelpLink = "https://www.pushbullet.com/#settings/account")] public string ApiKey { get; set; } - [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs, use device_iden in the device's URL on pushbullet.com (leave blank to send to all devices)", Type = FieldType.Tag)] + [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device)] public IEnumerable DeviceIds { get; set; } [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverProxy.cs similarity index 95% rename from src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs rename to src/NzbDrone.Core/Notifications/Pushover/PushoverProxy.cs index c87c90900..2dd303c10 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/PushoverProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -29,6 +29,7 @@ namespace NzbDrone.Core.Notifications.Pushover var request = new RestRequest(Method.POST); request.AddParameter("token", settings.ApiKey); request.AddParameter("user", settings.UserKey); + request.AddParameter("device", string.Join(",", settings.Devices)); request.AddParameter("title", title); request.AddParameter("message", message); request.AddParameter("priority", settings.Priority); diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs index c03ea7cc6..29ac22032 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs @@ -1,7 +1,8 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; +using System.Collections.Generic; namespace NzbDrone.Core.Notifications.Pushover { @@ -22,25 +23,29 @@ namespace NzbDrone.Core.Notifications.Pushover public PushoverSettings() { Priority = 0; + Devices = new string[] { }; } //TODO: Get Pushover to change our app name (or create a new app) when we have a new logo - [FieldDefinition(0, Label = "API Key", HelpLink = "https://pushover.net/apps/clone/nzbdrone")] + [FieldDefinition(0, Label = "API Key", HelpLink = "https://pushover.net/apps/clone/lidarr")] public string ApiKey { get; set; } [FieldDefinition(1, Label = "User Key", HelpLink = "https://pushover.net/")] public string UserKey { get; set; } - [FieldDefinition(2, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(PushoverPriority) )] + [FieldDefinition(2, Label = "Devices", HelpText = "List of device names (leave blank to send to all devices)", Type = FieldType.Tag)] + public IEnumerable Devices { get; set; } + + [FieldDefinition(3, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(PushoverPriority))] public int Priority { get; set; } - [FieldDefinition(3, Label = "Retry", Type = FieldType.Textbox, HelpText = "Interval to retry Emergency alerts, minimum 30 seconds")] + [FieldDefinition(4, Label = "Retry", Type = FieldType.Textbox, HelpText = "Interval to retry Emergency alerts, minimum 30 seconds")] public int Retry { get; set; } - [FieldDefinition(4, Label = "Expire", Type = FieldType.Textbox, HelpText = "Maximum time to retry Emergency alerts, maximum 86400 seconds")] + [FieldDefinition(5, Label = "Expire", Type = FieldType.Textbox, HelpText = "Maximum time to retry Emergency alerts, maximum 86400 seconds")] public int Expire { get; set; } - [FieldDefinition(5, Label = "Sound", Type = FieldType.Textbox, HelpText = "Notification sound, leave blank to use the default", HelpLink = "https://pushover.net/api#sounds")] + [FieldDefinition(6, Label = "Sound", Type = FieldType.Textbox, HelpText = "Notification sound, leave blank to use the default", HelpLink = "https://pushover.net/api#sounds")] public string Sound { get; set; } public bool IsValid => !string.IsNullOrWhiteSpace(UserKey) && Priority >= -1 && Priority <= 2; diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 3e751dc56..4df412642 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -900,7 +900,10 @@ + + + @@ -1027,7 +1030,6 @@ -