diff --git a/src/NzbDrone.Core/Notifications/Subsonic/Subsonic.cs b/src/NzbDrone.Core/Notifications/Subsonic/Subsonic.cs new file mode 100644 index 000000000..66f30a9e5 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/Subsonic.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class Subsonic : NotificationBase + { + private readonly ISubsonicService _subsonicService; + private readonly Logger _logger; + + public Subsonic(ISubsonicService subsonicService, Logger logger) + { + _subsonicService = subsonicService; + _logger = logger; + } + + public override string Link => "http://subsonic.org/"; + + public override void OnGrab(GrabMessage grabMessage) + { + const string header = "Lidarr - Grabbed"; + + Notify(Settings, header, grabMessage.Message); + } + + public override void OnAlbumDownload(AlbumDownloadMessage message) + { + const string header = "Lidarr - Downloaded"; + + Notify(Settings, header, message.Message); + Update(); + } + + public override void OnDownload(TrackDownloadMessage message) + { + const string header = "Lidarr - Downloaded"; + + Notify(Settings, header, message.Message); + + } + + public override void OnRename(Artist artist) + { + Update(); + } + + public override string Name => "Subsonic"; + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_subsonicService.Test(Settings, "Success! Subsonic has been successfully configured!")); + + return new ValidationResult(failures); + } + + private void Notify(SubsonicSettings settings, string header, string message) + { + try + { + if (Settings.Notify) + { + _subsonicService.Notify(Settings, $"{header} - {message}"); + } + } + catch (SocketException ex) + { + var logMessage = $"Unable to connect to Subsonic Host: {Settings.Host}:{Settings.Port}"; + _logger.Debug(ex, logMessage); + } + } + + private void Update() + { + try + { + if (Settings.UpdateLibrary) + { + _subsonicService.Update(Settings); + } + } + catch (SocketException ex) + { + var logMessage = $"Unable to connect to Subsonic Host: {Settings.Host}:{Settings.Port}"; + _logger.Debug(ex, logMessage); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicAuthenticationException.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicAuthenticationException.cs new file mode 100644 index 000000000..8b72513e6 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicAuthenticationException.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class SubsonicAuthenticationException : SubsonicException + { + public SubsonicAuthenticationException(string message) : base(message) + { + } + + public SubsonicAuthenticationException(string message, params object[] args) + : base(message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicException.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicException.cs new file mode 100644 index 000000000..8f94df21a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicException.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class SubsonicException : NzbDroneException + { + public SubsonicException(string message) : base(message) + { + } + + public SubsonicException(string message, params object[] args) : base(message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs new file mode 100644 index 000000000..dbd16738a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs @@ -0,0 +1,133 @@ +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Rest; +using RestSharp; +using System.IO; +using System.Xml.Linq; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public interface ISubsonicServerProxy + { + void Notify(SubsonicSettings settings, string message); + void Update(SubsonicSettings settings); + string Version(SubsonicSettings settings); + } + + public class SubsonicServerProxy : ISubsonicServerProxy + { + private readonly Logger _logger; + + public SubsonicServerProxy(Logger logger) + { + _logger = logger; + } + + public void Notify(SubsonicSettings settings, string message) + { + var resource = "addChatMessage"; + var request = GetSubsonicServerRequest(resource, Method.GET, settings); + request.AddParameter("message", message); + var client = GetSubsonicServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Update response: {0}", response.Content); + CheckForError(response, settings); + } + + public void Update(SubsonicSettings settings) + { + var resource = "startScan"; + var request = GetSubsonicServerRequest(resource, Method.GET, settings); + var client = GetSubsonicServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Update response: {0}", response.Content); + CheckForError(response, settings); + } + + public string Version(SubsonicSettings settings) + { + var request = GetSubsonicServerRequest("ping", Method.GET, settings); + var client = GetSubsonicServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Version response: {0}", response.Content); + CheckForError(response, settings); + + var xDoc = XDocument.Load(new StringReader(response.Content.Replace("&", "&"))); + var version = xDoc.Root?.Attribute("version")?.Value; + + if (version == null) + { + throw new SubsonicException("Could not read version from Subsonic"); + } + + return version; + } + + private RestClient GetSubsonicServerClient(SubsonicSettings settings) + { + var protocol = settings.UseSsl ? "https" : "http"; + + return RestClientFactory.BuildClient(string.Format("{0}://{1}:{2}/rest", protocol, settings.Host, settings.Port)); + } + + private RestRequest GetSubsonicServerRequest(string resource, Method method, SubsonicSettings settings) + { + var request = new RestRequest(resource, method); + + if (settings.Username.IsNotNullOrWhiteSpace()) + { + request.AddParameter("u", settings.Username); + request.AddParameter("p", settings.Password); + request.AddParameter("c", "Lidarr"); + request.AddParameter("v", "1.15.0"); + } + + return request; + } + + private void CheckForError(IRestResponse response, SubsonicSettings settings) + { + _logger.Trace("Checking for error"); + + var xDoc = XDocument.Load(new StringReader(response.Content.Replace("&", "&"))); + var status = xDoc.Root?.Attribute("status")?.Value; + + if (status == null) + { + throw new SubsonicException("Invalid Response, Check Server Settings"); + } + + if (status == "failed") + { + var ns = xDoc.Root.GetDefaultNamespace(); + var error = xDoc.Root.Element(XName.Get("error", ns.ToString())); + var errorMessage = error?.Attribute("message")?.Value; + var errorCode = error?.Attribute("code")?.Value; + + if (errorCode == null) + { + throw new SubsonicException("Subsonic returned error, check settings"); + } + + if (errorCode == "40") + { + throw new SubsonicAuthenticationException(errorMessage); + } + + throw new SubsonicException(errorMessage); + + } + + if (response.Content.IsNullOrWhiteSpace()) + { + _logger.Trace("No response body returned, no error detected"); + return; + } + + _logger.Trace("No error detected"); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicService.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicService.cs new file mode 100644 index 000000000..a2f5b09e5 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicService.cs @@ -0,0 +1,68 @@ +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public interface ISubsonicService + { + void Notify(SubsonicSettings settings, string message); + void Update(SubsonicSettings settings); + ValidationFailure Test(SubsonicSettings settings, string message); + } + + public class SubsonicService : ISubsonicService + { + private readonly ISubsonicServerProxy _proxy; + private readonly Logger _logger; + + public SubsonicService(ISubsonicServerProxy proxy, + Logger logger) + { + _proxy = proxy; + _logger = logger; + + } + + public void Notify(SubsonicSettings settings, string message) + { + _proxy.Notify(settings, message); + } + + public void Update(SubsonicSettings settings) + { + _proxy.Update(settings); + } + + private string GetVersion(SubsonicSettings settings) + { + var result = _proxy.Version(settings); + + return result; + } + + public ValidationFailure Test(SubsonicSettings settings, string message) + { + try + { + _logger.Debug("Determining version of Host: {0}", settings.Address); + var version = GetVersion(settings); + _logger.Debug("Version is: {0}", version); + } + catch (SubsonicAuthenticationException ex) + { + _logger.Error(ex, "Unable to connect to Subsonic Server"); + return new ValidationFailure("Username", "Incorrect username or password"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to connect to Subsonic Server"); + return new ValidationFailure("Host", "Unable to connect to Subsonic Server"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicSettings.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicSettings.cs new file mode 100644 index 000000000..614e5357c --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicSettings.cs @@ -0,0 +1,55 @@ +using FluentValidation; +using Newtonsoft.Json; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class SubsonicSettingsValidator : AbstractValidator + { + public SubsonicSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + } + } + + public class SubsonicSettings : IProviderConfig + { + private static readonly SubsonicSettingsValidator Validator = new SubsonicSettingsValidator(); + + public SubsonicSettings() + { + Port = 4040; + } + + [FieldDefinition(0, Label = "Host")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Username")] + public string Username { get; set; } + + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(4, Label = "Notify with Chat Message", Type = FieldType.Checkbox)] + public bool Notify { get; set; } + + [FieldDefinition(5, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Checkbox)] + public bool UpdateLibrary { get; set; } + + [FieldDefinition(6, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Connect to Subsonic over HTTPS instead of HTTP")] + public bool UseSsl { get; set; } + + [JsonIgnore] + public string Address => string.Format("{0}:{1}", Host, Port); + + 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 85fcd32c1..cddd548f9 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -896,6 +896,12 @@ + + + + + +