Added: Device load support for Pushbullet

Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
pull/6/head
Qstick 6 years ago
parent e41f884153
commit 23bc5b11cf

@ -0,0 +1,8 @@
.deviceInputWrapper {
display: flex;
}
.inputContainer {
composes: inputContainer from './TagInput.css';
composes: hasButton from 'Components/Form/Input.css';
}

@ -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 (
<div className={className}>
<TagInput
className={styles.inputContainer}
tags={selectedDevices}
tagList={items}
allowNew={true}
minQueryLength={0}
hasError={hasError}
hasWarning={hasWarning}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={isFetching}
/>
</FormInputButton>
</div>
);
}
}
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;

@ -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 (
<DeviceInput
{...this.props}
onRefreshPress={this.onRefreshPress}
/>
);
}
}
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);

@ -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;

@ -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':

@ -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: '',

@ -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
};

@ -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,

@ -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);

@ -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,

@ -34,6 +34,7 @@ namespace NzbDrone.Core.Annotations
Action,
Url,
Captcha,
OAuth
OAuth,
Device
}
}

@ -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<string, string> query)
{
if (action == "getDevices")
{
// Return early if there is not an API key
if (Settings.ApiKey.IsNullOrWhiteSpace())
{
return new
{
devices = new List<object>()
};
}
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 { };
}
}
}

@ -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; }
}
}

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace NzbDrone.Core.Notifications.PushBullet
{
public class PushBulletDevicesResponse
{
public List<PushBulletDevice> Devices { get; set; }
}
}

@ -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<PushBulletDevice> 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<PushBulletDevice> 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<PushBulletDevicesResponse>(response.Content).Devices;
}
catch (RestException ex)
{
if (ex.Response.StatusCode == HttpStatusCode.Unauthorized)
{
_logger.Error(ex, "Access token is invalid");
throw;
}
}
return new List<PushBulletDevice>();
}
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;
}

@ -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<string> DeviceIds { get; set; }
[FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)]

@ -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);

@ -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<string> 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;

@ -900,7 +900,10 @@
<Compile Include="Notifications\Plex\HomeTheater\PlexClientService.cs" />
<Compile Include="Notifications\Plex\Server\PlexServer.cs" />
<Compile Include="Notifications\Plex\Server\PlexServerProxy.cs" />
<Compile Include="Notifications\PushBullet\PushBulletDevice.cs" />
<Compile Include="Notifications\PushBullet\PushBulletDevicesResponse.cs" />
<Compile Include="Notifications\PushBullet\PushBulletException.cs" />
<Compile Include="Notifications\Pushover\PushoverProxy.cs" />
<Compile Include="Notifications\Slack\Payloads\Attachment.cs" />
<Compile Include="Notifications\Slack\Payloads\SlackPayload.cs" />
<Compile Include="Notifications\Slack\Slack.cs" />
@ -1027,7 +1030,6 @@
<Compile Include="Notifications\Pushover\InvalidResponseException.cs" />
<Compile Include="Notifications\Pushover\Pushover.cs" />
<Compile Include="Notifications\Pushover\PushoverPriority.cs" />
<Compile Include="Notifications\Pushover\PushoverService.cs" />
<Compile Include="Notifications\Pushover\PushoverSettings.cs" />
<Compile Include="Notifications\Xbmc\XbmcJsonException.cs" />
<Compile Include="Notifications\Xbmc\HttpApiProvider.cs" />

Loading…
Cancel
Save