From c5b25bcfeed29bc1207077560d59a30bed5e7344 Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Sun, 23 Aug 2015 10:51:19 -0700 Subject: [PATCH] New: Add Webhook support to sonarr Add Form type url (type=url input field) Add isValidUrl input type validation Only allow absolute urls when checking if a url is valid String => string as per comments that sonarr is standarizing on the lowercase primative Remove this before function calls Refactored everything so OnGrab is supported Don't double submit the webhook Wrappers around Series, EpisodeFile, Episode so the entire data structure isn't exposed Add Braces as per style guide Series.ID and Series.TvdbId should be integers Reorder webhook payload as per style guide Upgrade to use ongrab as json instead of string Add method selection to webhook settings include episode directly in download event QualityVersion should be an int and not a string (don't convert it int=>string) Remove the list of episodes Add season number to episode data structure Code Review Fixes: * Remove episodefile from payload, move everything to episode * Change episode to a list convert to var as per code review / style guide Down with internals Everything now uses webhookpayload. None of that payload.Message stuff {"EventType":"Test","Series":{"Id":1,"Title":"Test Title","Path":"C:\\testpath","TvdbId":1234},"Episodes":[{"Id":123,"EpisodeNumber":1,"SeasonNumber":1,"Title":"Test title","AirDate":null,"AirDateUtc":null,"Quality":null,"QualityVersion":0,"ReleaseGroup":null,"SceneName":null}]} Remove logger and processProvider Remove unused constructor --- .../Extensions/UrlExtensions.cs | 32 +++++ src/NzbDrone.Common/NzbDrone.Common.csproj | 1 + .../Annotations/FieldDefinitionAttribute.cs | 3 +- .../Notifications/Webhook/Webhook.cs | 55 ++++++++ .../Notifications/Webhook/WebhookEpisode.cs | 32 +++++ .../Notifications/Webhook/WebhookException.cs | 16 +++ .../Notifications/Webhook/WebhookMethod.cs | 13 ++ .../Notifications/Webhook/WebhookPayload.cs | 11 ++ .../Notifications/Webhook/WebhookSeries.cs | 22 ++++ .../Notifications/Webhook/WebhookService.cs | 120 ++++++++++++++++++ .../Notifications/Webhook/WebhookSettings.cs | 38 ++++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 9 ++ src/NzbDrone.Core/Validation/UrlValidator.cs | 28 ++++ src/UI/Form/FormBuilder.js | 6 +- src/UI/Form/UrlTemplate.hbs | 8 ++ 15 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 src/NzbDrone.Common/Extensions/UrlExtensions.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/Webhook.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookException.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs create mode 100644 src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs create mode 100644 src/NzbDrone.Core/Validation/UrlValidator.cs create mode 100644 src/UI/Form/UrlTemplate.hbs diff --git a/src/NzbDrone.Common/Extensions/UrlExtensions.cs b/src/NzbDrone.Common/Extensions/UrlExtensions.cs new file mode 100644 index 000000000..7808feb34 --- /dev/null +++ b/src/NzbDrone.Common/Extensions/UrlExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Extensions +{ + public static class UrlExtensions + { + public static bool IsValidUrl(this string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + Uri uri; + if (!Uri.TryCreate(path, UriKind.Absolute, out uri)) + { + return false; + } + + if (!uri.IsWellFormedOriginalString()) + { + return false; + } + + return true; + + } + } +} diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index f2b0ac12b..265234252 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -138,6 +138,7 @@ + diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 2d6dc5249..4406fa73f 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -28,6 +28,7 @@ namespace NzbDrone.Core.Annotations Path, Hidden, Tag, - Action + Action, + Url } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs b/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs new file mode 100644 index 000000000..12f72dd91 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs @@ -0,0 +1,55 @@ + +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Core.Tv; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class Webhook : NotificationBase + { + private readonly IWebhookService _service; + + public Webhook(IWebhookService service) + { + _service = service; + } + + public override string Link + { + get { return "https://github.com/Sonarr/Sonarr/wiki/Webhook"; } + } + + public override void OnGrab(GrabMessage message) + { + _service.OnGrab(message.Series, message.Episode, message.Quality, Settings); + } + + public override void OnDownload(DownloadMessage message) + { + _service.OnDownload(message.Series, message.EpisodeFile, Settings); + } + + public override void OnRename(Series series) + { + _service.OnRename(series, Settings); + } + + public override string Name + { + get + { + return "Webhook"; + } + } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_service.Test(Settings)); + + return new ValidationResult(failures); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs new file mode 100644 index 000000000..a7979b726 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs @@ -0,0 +1,32 @@ +using NzbDrone.Core.Tv; +using System; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookEpisode + { + public WebhookEpisode() { } + + public WebhookEpisode(Episode episode) + { + Id = episode.Id; + SeasonNumber = episode.SeasonNumber; + EpisodeNumber = episode.EpisodeNumber; + Title = episode.Title; + AirDate = episode.AirDate; + AirDateUtc = episode.AirDateUtc; + } + + public int Id { get; set; } + public int EpisodeNumber { get; set; } + public int SeasonNumber { get; set; } + public string Title { get; set; } + public string AirDate { get; set; } + public DateTime? AirDateUtc { get; set; } + + public string Quality { get; set; } + public int QualityVersion { get; set; } + public string ReleaseGroup { get; set; } + public string SceneName { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookException.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookException.cs new file mode 100644 index 000000000..b422d8621 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookException.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookException : NzbDroneException + { + public WebhookException(string message) : base(message) + { + } + + public WebhookException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs new file mode 100644 index 000000000..6ca862edb --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookMethod.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public enum WebhookMethod + { + POST = RestSharp.Method.POST, + PUT = RestSharp.Method.PUT + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs new file mode 100644 index 000000000..41009a695 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookPayload.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookPayload + { + public string EventType { get; set; } + public WebhookSeries Series { get; set; } + public List Episodes { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs new file mode 100644 index 000000000..222f9eebb --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookSeries + { + public int Id { get; set; } + public string Title { get; set; } + public string Path { get; set; } + public int TvdbId { get; set; } + + public WebhookSeries() { } + + public WebhookSeries(Series series) + { + Id = series.Id; + Title = series.Title; + Path = series.Path; + TvdbId = series.TvdbId; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs new file mode 100644 index 000000000..982f502be --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookService.cs @@ -0,0 +1,120 @@ +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Processes; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Rest; +using RestSharp; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public interface IWebhookService + { + void OnDownload(Series series, EpisodeFile episodeFile, WebhookSettings settings); + void OnRename(Series series, WebhookSettings settings); + void OnGrab(Series series, RemoteEpisode episode, QualityModel quality, WebhookSettings settings); + ValidationFailure Test(WebhookSettings settings); + } + + public class WebhookService : IWebhookService + { + public void OnDownload(Series series, EpisodeFile episodeFile, WebhookSettings settings) + { + var payload = new WebhookPayload + { + EventType = "Download", + Series = new WebhookSeries(series), + Episodes = episodeFile.Episodes.Value.ConvertAll(x => new WebhookEpisode(x) { + Quality = episodeFile.Quality.Quality.Name, + QualityVersion = episodeFile.Quality.Revision.Version, + ReleaseGroup = episodeFile.ReleaseGroup, + SceneName = episodeFile.SceneName + }) + }; + + NotifyWebhook(payload, settings); + } + + public void OnRename(Series series, WebhookSettings settings) + { + var payload = new WebhookPayload + { + EventType = "Rename", + Series = new WebhookSeries(series) + }; + + NotifyWebhook(payload, settings); + } + + public void OnGrab(Series series, RemoteEpisode episode, QualityModel quality, WebhookSettings settings) + { + var payload = new WebhookPayload + { + EventType = "Grab", + Series = new WebhookSeries(series), + Episodes = episode.Episodes.ConvertAll(x => new WebhookEpisode(x) + { + Quality = quality.Quality.Name, + QualityVersion = quality.Revision.Version, + ReleaseGroup = episode.ParsedEpisodeInfo.ReleaseGroup + }) + }; + NotifyWebhook(payload, settings); + } + + public void NotifyWebhook(WebhookPayload body, WebhookSettings settings) + { + try { + var client = RestClientFactory.BuildClient(settings.Url); + var request = new RestRequest((Method) settings.Method); + request.RequestFormat = DataFormat.Json; + request.AddBody(body); + client.ExecuteAndValidate(request); + } + catch (RestException ex) + { + throw new WebhookException("Unable to post to webhook: {0}", ex, ex.Message); + } + } + + public ValidationFailure Test(WebhookSettings settings) + { + try + { + NotifyWebhook( + new WebhookPayload + { + EventType = "Test", + Series = new WebhookSeries() + { + Id = 1, + Title = "Test Title", + Path = "C:\\testpath", + TvdbId = 1234 + }, + Episodes = new List() { + new WebhookEpisode() + { + Id = 123, + EpisodeNumber = 1, + SeasonNumber = 1, + Title = "Test title" + } + } + }, + settings + ); + } + catch (WebhookException ex) + { + return new NzbDroneValidationFailure("Url", ex.Message); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs new file mode 100644 index 000000000..f1584e16a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs @@ -0,0 +1,38 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookSettingsValidator : AbstractValidator + { + public WebhookSettingsValidator() + { + RuleFor(c => c.Url).IsValidUrl(); + } + } + + public class WebhookSettings : IProviderConfig + { + private static readonly WebhookSettingsValidator Validator = new WebhookSettingsValidator(); + + public WebhookSettings() + { + Method = Convert.ToInt32(WebhookMethod.POST); + } + + [FieldDefinition(0, Label = "URL", Type = FieldType.Url)] + public string Url { get; set; } + + [FieldDefinition(1, Label = "Method", Type = FieldType.Select, SelectOptions = typeof(WebhookMethod), HelpText = "Which HTTP method to use submit to the Webservice")] + public Int32 Method { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 19949d436..d0b5703d0 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -746,6 +746,14 @@ + + + + + + + + @@ -992,6 +1000,7 @@ + diff --git a/src/NzbDrone.Core/Validation/UrlValidator.cs b/src/NzbDrone.Core/Validation/UrlValidator.cs new file mode 100644 index 000000000..a52c14a90 --- /dev/null +++ b/src/NzbDrone.Core/Validation/UrlValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Validation +{ + public static class UrlValidation + { + public static IRuleBuilderOptions IsValidUrl(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.SetValidator(new UrlValidator()); + } + } + + public class UrlValidator : PropertyValidator + { + public UrlValidator() + : base("Invalid Url") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return false; + return context.PropertyValue.ToString().IsValidUrl(); + } + } +} \ No newline at end of file diff --git a/src/UI/Form/FormBuilder.js b/src/UI/Form/FormBuilder.js index d68608dea..53700b614 100644 --- a/src/UI/Form/FormBuilder.js +++ b/src/UI/Form/FormBuilder.js @@ -17,6 +17,10 @@ var _fieldBuilder = function(field) { return _templateRenderer.call(field, 'Form/HiddenTemplate'); } + if (field.type === 'url') { + return _templateRenderer.call(field, 'Form/UrlTemplate'); + } + if (field.type === 'password') { return _templateRenderer.call(field, 'Form/PasswordTemplate'); } @@ -56,4 +60,4 @@ Handlebars.registerHelper('formBuilder', function() { }); return new Handlebars.SafeString(ret); -}); \ No newline at end of file +}); diff --git a/src/UI/Form/UrlTemplate.hbs b/src/UI/Form/UrlTemplate.hbs new file mode 100644 index 000000000..7f41272f1 --- /dev/null +++ b/src/UI/Form/UrlTemplate.hbs @@ -0,0 +1,8 @@ +
+ + +
+ +
+ {{> FormHelpPartial}} +