From 77e9493ccfed43fd7d382d1b4e25a702d3874495 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 28 Jun 2015 01:50:19 -0700 Subject: [PATCH] Improved PushBullet implementation (v2 API, multiple devices, channels) New: PushBullet supports multiple devices New: PushBullet channels Closes #641 --- .../086_pushbullet_device_idsFixture.cs | 90 +++++++++++ .../NzbDrone.Core.Test.csproj | 1 + .../Annotations/FieldDefinitionAttribute.cs | 3 +- .../Migration/086_pushbullet_device_ids.cs | 55 +++++++ .../Notifications/PushBullet/PushBullet.cs | 4 +- .../PushBullet/PushBulletException.cs | 16 ++ .../PushBullet/PushBulletProxy.cs | 142 +++++++++++++++--- .../PushBullet/PushBulletSettings.cs | 13 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 2 + src/UI/Form/FormBuilder.js | 4 + src/UI/Form/TagTemplate.hbs | 9 ++ .../Edit/NotificationEditView.js | 10 +- 12 files changed, 312 insertions(+), 37 deletions(-) create mode 100644 src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs create mode 100644 src/NzbDrone.Core/Notifications/PushBullet/PushBulletException.cs create mode 100644 src/UI/Form/TagTemplate.hbs diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs new file mode 100644 index 000000000..a97eb3e2b --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs @@ -0,0 +1,90 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.Notifications.PushBullet; +using NzbDrone.Core.Notifications.Pushover; +using NzbDrone.Core.Test.Framework; +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class pushbullet_device_idsFixture : MigrationTest + { + [Test] + public void should_not_fail_if_no_pushbullet() + { + WithTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = false, + OnDownload = false, + OnUpgrade = false, + Name = "Pushover", + Implementation = "Pushover", + Settings = new PushoverSettings().ToJson(), + ConfigContract = "PushoverSettings" + }); + }); + + var items = Mocker.Resolve().All(); + + items.Should().HaveCount(1); + } + + [Test] + public void should_not_fail_if_deviceId_is_not_set() + { + WithTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = false, + OnDownload = false, + OnUpgrade = false, + Name = "PushBullet", + Implementation = "PushBullet", + Settings = new + { + ApiKey = "my_api_key", + }.ToJson(), + ConfigContract = "PushBulletSettings" + }); + }); + + var items = Mocker.Resolve().All(); + + items.Should().HaveCount(1); + } + + [Test] + public void should_add_deviceIds_setting_matching_deviceId() + { + var deviceId = "device_id"; + + WithTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = false, + OnDownload = false, + OnUpgrade = false, + Name = "PushBullet", + Implementation = "PushBullet", + Settings = new + { + ApiKey = "my_api_key", + DeviceId = deviceId + }.ToJson(), + ConfigContract = "PushBulletSettings" + }); + }); + + var items = Mocker.Resolve().All(); + + items.Should().HaveCount(1); + items.First().Settings.As().DeviceIds.Should().Be(deviceId); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index d79eda84f..09606e07f 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -121,6 +121,7 @@ + diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index f34ca78b9..21fec2e11 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.Annotations Checkbox, Select, Path, - Hidden + Hidden, + Tag } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs b/src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs new file mode 100644 index 000000000..9e89a0513 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Data; +using FluentMigrator; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(86)] + public class pushbullet_device_ids : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(UpdateTransmissionSettings); + } + + private void UpdateTransmissionSettings(IDbConnection conn, IDbTransaction tran) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT Id, Settings FROM Notifications WHERE Implementation = 'PushBullet'"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settingsJson = reader.GetString(1); + var settings = Json.Deserialize>(settingsJson); + + if (settings.ContainsKey("deviceId")) + { + var deviceId = settings.GetValueOrDefault("deviceId", "") as string; + + settings.Add("deviceIds", deviceId); + settings.Remove("deviceId"); + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE Notifications SET Settings = ? WHERE Id = ?"; + updateCmd.AddParameter(settings.ToJson()); + updateCmd.AddParameter(id); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs index f1341c99a..c0e155244 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs @@ -23,14 +23,14 @@ namespace NzbDrone.Core.Notifications.PushBullet { const string title = "Sonarr - Episode Grabbed"; - _proxy.SendNotification(title, message, Settings.ApiKey, Settings.DeviceId); + _proxy.SendNotification(title, message, Settings); } public override void OnDownload(DownloadMessage message) { const string title = "Sonarr - Episode Downloaded"; - _proxy.SendNotification(title, message.Message, Settings.ApiKey, Settings.DeviceId); + _proxy.SendNotification(title, message.Message, Settings); } public override void AfterRename(Series series) diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletException.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletException.cs new file mode 100644 index 000000000..ce8b8417d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletException.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.PushBullet +{ + public class PushBulletException : NzbDroneException + { + public PushBulletException(string message) : base(message) + { + } + + public PushBulletException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs index 068f0b6d2..993651c68 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using FluentValidation.Results; using NLog; +using NzbDrone.Common.Extensions; using RestSharp; using NzbDrone.Core.Rest; @@ -9,49 +12,82 @@ namespace NzbDrone.Core.Notifications.PushBullet { public interface IPushBulletProxy { - void SendNotification(string title, string message, string apiKey, string deviceId); + void SendNotification(string title, string message, PushBulletSettings settings); ValidationFailure Test(PushBulletSettings settings); } public class PushBulletProxy : IPushBulletProxy { private readonly Logger _logger; - private const string URL = "https://api.pushbullet.com/api/pushes"; + private const string URL = "https://api.pushbullet.com/v2/pushes"; public PushBulletProxy(Logger logger) { _logger = logger; } - public void SendNotification(string title, string message, string apiKey, string deviceId) + public void SendNotification(string title, string message, PushBulletSettings settings) { - var client = RestClientFactory.BuildClient(URL); - var request = BuildRequest(deviceId); - - request.AddParameter("type", "note"); - request.AddParameter("title", title); - request.AddParameter("body", message); - - client.Authenticator = new HttpBasicAuthenticator(apiKey, String.Empty); - client.ExecuteAndValidate(request); - } - - public RestRequest BuildRequest(string deviceId) - { - var request = new RestRequest(Method.POST); - long integerId; + var channelTags = GetIds(settings.ChannelTags); + var deviceIds = GetIds(settings.DeviceIds); + var error = false; - if (Int64.TryParse(deviceId, out integerId)) + if (channelTags.Any()) { - request.AddParameter("device_id", integerId); - } + foreach (var channelTag in channelTags) + { + var request = BuildChannelRequest(channelTag); + try + { + SendNotification(title, message, request, settings); + } + catch (PushBulletException ex) + { + _logger.ErrorException("Unable to send test message to: " + channelTag, ex); + error = true; + } + } + } else { - request.AddParameter("device_iden", deviceId); + if (deviceIds.Any()) + { + foreach (var deviceId in deviceIds) + { + var request = BuildDeviceRequest(deviceId); + + try + { + SendNotification(title, message, request, settings); + } + catch (PushBulletException ex) + { + _logger.ErrorException("Unable to send test message to: " + deviceId, ex); + error = true; + } + } + } + else + { + var request = BuildDeviceRequest(null); + + try + { + SendNotification(title, message, request, settings); + } + catch (PushBulletException ex) + { + _logger.ErrorException("Unable to send test message to all devices", ex); + error = true; + } + } } - return request; + if (error) + { + throw new PushBulletException("Unable to send PushBullet notifications to all channels or devices"); + } } public ValidationFailure Test(PushBulletSettings settings) @@ -61,7 +97,7 @@ namespace NzbDrone.Core.Notifications.PushBullet const string title = "Sonarr - Test Notification"; const string body = "This is a test message from Sonarr"; - SendNotification(title, body, settings.ApiKey, settings.DeviceId); + SendNotification(title, body, settings); } catch (RestException ex) { @@ -82,5 +118,63 @@ namespace NzbDrone.Core.Notifications.PushBullet return null; } + + private RestRequest BuildDeviceRequest(string deviceId) + { + var request = new RestRequest(Method.POST); + long integerId; + + if (Int64.TryParse(deviceId, out integerId)) + { + request.AddParameter("device_id", integerId); + } + + else + { + request.AddParameter("device_iden", deviceId); + } + + return request; + } + + private RestRequest BuildChannelRequest(string channelTag) + { + var request = new RestRequest(Method.POST); + request.AddParameter("channel_tag", channelTag); + + return request; + } + + private void SendNotification(string title, string message, RestRequest request, PushBulletSettings settings) + { + try + { + var client = RestClientFactory.BuildClient(URL); + + request.AddParameter("type", "note"); + request.AddParameter("title", title); + request.AddParameter("body", message); + + client.Authenticator = new HttpBasicAuthenticator(settings.ApiKey, String.Empty); + client.ExecuteAndValidate(request); + } + catch (RestException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.ErrorException("API Key is invalid: " + ex.Message, ex); + throw; + } + + throw new PushBulletException("Unable to send text message: {0}", ex, ex.Message); + } + } + + private List GetIds(string input) + { + if (input.IsNullOrWhiteSpace()) return new List(); + + return input.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries).ToList(); + } } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs index b44724232..83dcfd7c4 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs @@ -21,16 +21,11 @@ namespace NzbDrone.Core.Notifications.PushBullet [FieldDefinition(0, Label = "API Key", HelpLink = "https://www.pushbullet.com/")] public String ApiKey { get; set; } - [FieldDefinition(1, Label = "Device ID", HelpText = "device_iden in the device's URL on pushbullet.com (leave blank to send to all devices)")] - public String DeviceId { 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)] + public String DeviceIds { get; set; } - public bool IsValid - { - get - { - return !String.IsNullOrWhiteSpace(ApiKey) && !String.IsNullOrWhiteSpace(DeviceId); - } - } + [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] + public String ChannelTags { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 462b84e13..71a23ba27 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -254,6 +254,7 @@ + @@ -708,6 +709,7 @@ + diff --git a/src/UI/Form/FormBuilder.js b/src/UI/Form/FormBuilder.js index da3677f04..7ecfb29ec 100644 --- a/src/UI/Form/FormBuilder.js +++ b/src/UI/Form/FormBuilder.js @@ -32,6 +32,10 @@ var _fieldBuilder = function(field) { return _templateRenderer.call(field, 'Form/PathTemplate'); } + if (field.type === 'tag') { + return _templateRenderer.call(field, 'Form/TagTemplate'); + } + return _templateRenderer.call(field, 'Form/TextboxTemplate'); }; diff --git a/src/UI/Form/TagTemplate.hbs b/src/UI/Form/TagTemplate.hbs new file mode 100644 index 000000000..4df3ca6ba --- /dev/null +++ b/src/UI/Form/TagTemplate.hbs @@ -0,0 +1,9 @@ +
+ + +
+ +
+ + {{> FormHelpPartial}} +
\ No newline at end of file diff --git a/src/UI/Settings/Notifications/Edit/NotificationEditView.js b/src/UI/Settings/Notifications/Edit/NotificationEditView.js index 1f0d3ea69..735f725a7 100644 --- a/src/UI/Settings/Notifications/Edit/NotificationEditView.js +++ b/src/UI/Settings/Notifications/Edit/NotificationEditView.js @@ -6,6 +6,7 @@ var AsValidatedView = require('../../../Mixins/AsValidatedView'); var AsEditModalView = require('../../../Mixins/AsEditModalView'); require('../../../Form/FormBuilder'); require('../../../Mixins/TagInput'); +require('bootstrap.tagsinput'); var view = Marionette.ItemView.extend({ template : 'Settings/Notifications/Edit/NotificationEditViewTemplate', @@ -13,7 +14,8 @@ var view = Marionette.ItemView.extend({ ui : { onDownloadToggle : '.x-on-download', onUpgradeSection : '.x-on-upgrade', - tags : '.x-tags' + tags : '.x-tags', + formTag : '.x-form-tag' }, events : { @@ -29,10 +31,16 @@ var view = Marionette.ItemView.extend({ onRender : function() { this._onDownloadChanged(); + this.ui.tags.tagInput({ model : this.model, property : 'tags' }); + + this.ui.formTag.tagsinput({ + trimValue : true, + tagClass : 'label label-default' + }); }, _onAfterSave : function() {