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